feat: add production management frontend with CI/CD pipeline

This commit is contained in:
Chenwei Jiang 2026-07-04 02:29:21 +00:00
parent 0eef618bc1
commit b66d113ef8
18 changed files with 1237 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
.git
.gitignore
.superpowers
__mocks__
test
*.md
.umirc.local.ts

3
.gitignore vendored
View File

@ -2,6 +2,9 @@
# Created by https://www.toptal.com/developers/gitignore/api/node,macos,visualstudiocode # Created by https://www.toptal.com/developers/gitignore/api/node,macos,visualstudiocode
# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,visualstudiocode # Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,visualstudiocode
# Allow src/pages Logs (overrides 'logs' ignore rule below)
!src/pages/**/Logs/
### macOS ### ### macOS ###
# General # General
.DS_Store .DS_Store

46
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,46 @@
variables:
HARBOR_REGISTRY: harbor-in-k3s.cheverjohn.me
HARBOR_PROJECT: iloom
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_HOST: tcp://localhost:2376
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: /certs/client
stages:
- build
.build-template:
stage: build
image: docker:27
services:
- name: docker:27-dind
command: ["--dns", "10.43.0.10", "--dns", "223.5.5.5"]
retry: 2
before_script:
- until docker info >/dev/null 2>&1; do echo "Waiting for Docker daemon..."; sleep 2; done
- echo "${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "${HARBOR_USER}" --password-stdin
script:
- |
if [ -n "$CI_COMMIT_TAG" ]; then
TAG="$CI_COMMIT_TAG"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
TAG="main-${CI_COMMIT_SHORT_SHA}"
else
TAG="feat-${CI_COMMIT_SHORT_SHA}"
fi
- IMAGE=${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${IMAGE_NAME}:${TAG}
- docker build --network host
--build-arg BASE_REGISTRY=${HARBOR_REGISTRY}/library
-f Dockerfile
-t ${IMAGE} .
- docker push ${IMAGE}
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG =~ /^v/
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH =~ /^feat\//
build-portal:
extends: .build-template
variables:
IMAGE_NAME: muyu-portal

View File

@ -83,6 +83,23 @@ export default defineConfig({
{ name: '采购报表', path: '/purchase/stats', component: './Purchase/Stats' }, { name: '采购报表', path: '/purchase/stats', component: './Purchase/Stats' },
], ],
}, },
{
name: '生产管理',
path: '/production',
icon: 'ExperimentOutlined',
routes: [
{ name: '生产计划', path: '/production/plans', component: './Production/Plans/index' },
{ name: '新建计划', path: '/production/plans/new', component: './Production/Plans/new', hideInMenu: true },
{ name: '计划详情', path: '/production/plans/:planId', component: './Production/Plans/detail', hideInMenu: true },
],
},
{
name: '分享链接',
path: '/share/production/:shortCode',
component: './Production/Share/index',
hideInMenu: true,
layout: false,
},
{ {
name: '系统管理', name: '系统管理',
path: '/system', path: '/system',

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
ARG BASE_REGISTRY=harbor-in-k3s.cheverjohn.me/library
FROM ${BASE_REGISTRY}/node:20-alpine AS builder
RUN sed -i "s|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g" /etc/apk/repositories
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --registry=https://registry.npmmirror.com
COPY . .
RUN pnpm build
FROM ${BASE_REGISTRY}/nginx:1.27-alpine
RUN sed -i "s|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g" /etc/apk/repositories
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

31
nginx.conf Normal file
View File

@ -0,0 +1,31 @@
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 256;
# muyu-gateway API 反向代理
location /api/ {
proxy_pass http://muyu-gateway:8888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}

View File

@ -0,0 +1,44 @@
import { ProColumns, ProTable } from '@ant-design/pro-components';
import { Tag } from 'antd';
interface InventoryLog {
id: number;
productName: string;
changeType: string;
quantity: number;
remark: string;
createdAt: string;
}
const columns: ProColumns<InventoryLog>[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
{
title: '变动类型',
dataIndex: 'changeType',
valueEnum: {
in: { text: '入库', status: 'Success' },
out: { text: '出库', status: 'Error' },
adjust: { text: '调整', status: 'Warning' },
},
},
{ title: '数量', dataIndex: 'quantity', search: false },
{ title: '备注', dataIndex: 'remark', search: false, ellipsis: true },
{ title: '变动时间', dataIndex: 'createdAt', search: false, valueType: 'dateTime' },
];
const LogsPage: React.FC = () => (
<ProTable<InventoryLog>
headerTitle="库存变动记录"
rowKey="id"
columns={columns}
request={async () => ({
data: [],
total: 0,
success: true,
})}
search={{ labelWidth: 'auto' }}
pagination={{ pageSize: 20 }}
/>
);
export default LogsPage;

View File

@ -0,0 +1,28 @@
import { describe, vi, beforeEach } from 'vitest';
vi.mock('@umijs/max', () => ({
request: vi.fn(() => Promise.resolve({ code: 0, msg: 'ok', success: true, data: { list: [], total: 0 } })),
history: { push: vi.fn(), replace: vi.fn(), back: vi.fn(), go: vi.fn(), location: { pathname: '/' } },
useModel: vi.fn(() => ({ initialState: { currentUser: { name: 'tester', menuPaths: [] } }, setInitialState: vi.fn() })),
useParams: vi.fn(() => ({ planId: 'plan-1' })),
useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]),
useNavigate: vi.fn(() => vi.fn()),
useLocation: vi.fn(() => ({ pathname: '/' })),
useAccess: vi.fn(() => ({})),
Link: ({ children }: any) => children,
NavLink: ({ children }: any) => children,
Access: ({ children }: any) => children,
Outlet: () => null,
}));
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './detail';
describe('Production / Plans / detail page', () => {
beforeEach(() => {
vi.clearAllMocks();
});
smokeTest(Page);
});

View File

@ -0,0 +1,331 @@
import { ArrowLeftOutlined, CopyOutlined, PlusOutlined, ShareAltOutlined } from '@ant-design/icons';
import { ProTable } from '@ant-design/pro-components';
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Row,
Space,
Steps,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import { useEffect, useState } from 'react';
import { history, useParams } from '@umijs/max';
import {
completeStep,
confirmPlan,
createInbound,
createShareLink,
getProductionPlan,
listInbound,
} from '@/services/api';
import type { InboundRecordInfo, ProductionPlanInfo, ShareLinkInfo } from '@/types/api';
const STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '草稿', color: 'default' },
1: { text: '待生产', color: 'processing' },
2: { text: '生产中', color: 'warning' },
3: { text: '已完成', color: 'success' },
4: { text: '已取消', color: 'error' },
};
const STEP_TYPES = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
const STEP_LABELS: Record<string, string> = {
confirm: '计划确认',
yarn_purchase: '纱线采购',
dyeing: '染色',
machine_start: '上机生产',
fabric_warehouse: '成品入库',
};
const STEP_STATUS_MAP: Record<number, 'wait' | 'process' | 'finish' | 'error'> = {
0: 'wait',
1: 'process',
2: 'finish',
};
const PlanDetailPage: React.FC = () => {
const { planId } = useParams<{ planId: string }>();
const [plan, setPlan] = useState<ProductionPlanInfo | null>(null);
const [inboundRecords, setInboundRecords] = useState<InboundRecordInfo[]>([]);
const [inboundTotal, setInboundTotal] = useState(0);
const [inboundModalOpen, setInboundModalOpen] = useState(false);
const [shareLink, setShareLink] = useState<ShareLinkInfo | null>(null);
const [inboundForm] = Form.useForm();
const loadPlan = async () => {
if (!planId) return;
const res = await getProductionPlan(planId);
if (res?.code === 0) setPlan(res.data);
};
const loadInbound = async () => {
if (!planId) return;
const res = await listInbound({ planId, page: 1, pageSize: 100 });
if (res?.code === 0) {
setInboundRecords(res.data?.list || []);
setInboundTotal(res.data?.total || 0);
}
};
useEffect(() => {
loadPlan();
loadInbound();
}, [planId]);
const handleConfirmPlan = async () => {
if (!planId) return;
await confirmPlan({ planId });
message.success('计划已确认');
loadPlan();
};
const handleCompleteStep = async (stepType: string) => {
if (!planId) return;
await completeStep({ planId, stepType });
message.success('工序已完成');
loadPlan();
};
const handleCreateInbound = async () => {
if (!planId) return;
const values = await inboundForm.validateFields();
await createInbound({ planId, ...values });
message.success('入库记录已创建');
setInboundModalOpen(false);
inboundForm.resetFields();
loadPlan();
loadInbound();
};
const handleCreateShareLink = async () => {
if (!planId) return;
const res = await createShareLink({ planId, expiresHours: 72 });
if (res?.code === 0 && res.data) {
setShareLink(res.data);
message.success('分享链接已生成');
}
};
const handleCopyLink = () => {
if (!shareLink) return;
const url = `${window.location.origin}/share/production/${shareLink.shortCode}`;
navigator.clipboard.writeText(url).then(() => message.success('链接已复制'));
};
if (!plan) return null;
const statusInfo = STATUS_MAP[plan.status] ?? STATUS_MAP[0];
// Build step items from STEP_TYPES with plan's processSteps data
const stepsByType = Object.fromEntries((plan.processSteps || []).map((s) => [s.stepType, s]));
const stepsItems = STEP_TYPES.map((type) => {
const step = stepsByType[type];
return {
title: STEP_LABELS[type],
status: step ? STEP_STATUS_MAP[step.status] ?? 'wait' : 'wait',
description: step?.completedAt || undefined,
};
});
return (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => history.back()}></Button>
<Typography.Title level={4} style={{ margin: 0 }}>
{plan.planCode}
</Typography.Title>
<Tag color={statusInfo.color}>{statusInfo.text}</Tag>
</Space>
{plan.status === 0 && (
<Space>
<Button type="primary" onClick={() => Modal.confirm({ title: '确认此生产计划?', onOk: handleConfirmPlan })}>
</Button>
<Button icon={<ShareAltOutlined />} onClick={handleCreateShareLink}></Button>
</Space>
)}
{plan.status === 1 && (
<Button icon={<ShareAltOutlined />} onClick={handleCreateShareLink}></Button>
)}
<Tabs
defaultActiveKey="info"
items={[
{
key: 'info',
label: '基本信息',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Card>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="计划编号">{plan.planCode}</Descriptions.Item>
<Descriptions.Item label="产品名称">{plan.productName}</Descriptions.Item>
<Descriptions.Item label="颜色">{plan.color}</Descriptions.Item>
<Descriptions.Item label="面料编号">{plan.fabricCode}</Descriptions.Item>
<Descriptions.Item label="色号">{plan.colorCode}</Descriptions.Item>
<Descriptions.Item label="目标数量">{plan.targetQuantity} </Descriptions.Item>
<Descriptions.Item label="已完成">{plan.completedQuantity} </Descriptions.Item>
<Descriptions.Item label="每米用纱量">{plan.yarnUsagePerMeter}</Descriptions.Item>
<Descriptions.Item label="生产单价">¥ {plan.productionPrice}</Descriptions.Item>
<Descriptions.Item label="开工日期">{plan.startTime}</Descriptions.Item>
<Descriptions.Item label="创建人">{plan.creator}</Descriptions.Item>
<Descriptions.Item label="创建时间">{plan.createdAt}</Descriptions.Item>
{plan.remark && <Descriptions.Item label="备注" span={3}>{plan.remark}</Descriptions.Item>}
</Descriptions>
</Card>
{plan.yarnRatios && plan.yarnRatios.length > 0 && (
<Card title="纱线配比">
<Table
rowKey="ratioId"
dataSource={plan.yarnRatios}
pagination={false}
size="small"
columns={[
{ title: '纱线名称', dataIndex: 'yarnName' },
{ title: '配比 (%)', dataIndex: 'ratio' },
{ title: '每米用量', dataIndex: 'amountPerMeter' },
{ title: '总用量', dataIndex: 'totalAmount' },
]}
/>
</Card>
)}
</Space>
),
},
{
key: 'process',
label: '生产流程',
children: (
<Card>
<Steps current={-1} items={stepsItems} />
<div style={{ marginTop: 24 }}>
{STEP_TYPES.map((type) => {
const step = stepsByType[type];
const isCompleted = step?.status === 2;
const canComplete = plan.status >= 1 && !isCompleted;
return canComplete ? (
<Button
key={type}
style={{ marginRight: 8, marginBottom: 8 }}
onClick={() => Modal.confirm({ title: `完成「${STEP_LABELS[type]}」工序?`, onOk: () => handleCompleteStep(type) })}
>
: {STEP_LABELS[type]}
</Button>
) : null;
})}
</div>
</Card>
),
},
{
key: 'inbound',
label: '入库记录',
children: (
<Card
title={`入库记录 (共 ${inboundTotal} 条)`}
extra={
<Button icon={<PlusOutlined />} onClick={() => { inboundForm.resetFields(); setInboundModalOpen(true); }}></Button>
}
>
<ProTable<InboundRecordInfo>
headerTitle={false}
rowKey="recordId"
search={false}
pagination={false}
dataSource={inboundRecords}
columns={[
{ title: '批次号', dataIndex: 'batchNo' },
{ title: '数量(米)', dataIndex: 'quantity' },
{ title: '匹数', dataIndex: 'rolls' },
{ title: '单价', dataIndex: 'pricePerMeter', render: (v) => `¥ ${v}` },
{ title: '备注', dataIndex: 'remark' },
{ title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime' },
]}
/>
</Card>
),
},
{
key: 'share',
label: '分享管理',
children: (
<Card>
<Space direction="vertical" style={{ width: '100%' }}>
<Button icon={<ShareAltOutlined />} onClick={handleCreateShareLink}></Button>
{shareLink && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="短码">{shareLink.shortCode}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={
shareLink.status === 0 ? 'processing' :
shareLink.status === 1 ? 'cyan' :
shareLink.status === 2 ? 'success' :
shareLink.status === 3 ? 'error' :
shareLink.status === 4 ? 'default' : 'default'
}>
{shareLink.status === 0 ? '待处理' :
shareLink.status === 1 ? '已点击' :
shareLink.status === 2 ? '已确认' :
shareLink.status === 3 ? '已拒绝' :
shareLink.status === 4 ? '已过期' : '未知'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="过期时间">{shareLink.expiresAt}</Descriptions.Item>
<Descriptions.Item label="点击数">{shareLink.clickCount}</Descriptions.Item>
<Descriptions.Item label="链接" span={2}>
<Space>
<Typography.Text copyable>{`${window.location.origin}/share/production/${shareLink.shortCode}`}</Typography.Text>
<Button icon={<CopyOutlined />} size="small" onClick={handleCopyLink}></Button>
</Space>
</Descriptions.Item>
</Descriptions>
)}
</Space>
</Card>
),
},
]}
/>
<Modal title="新增入库" open={inboundModalOpen} onOk={handleCreateInbound} onCancel={() => setInboundModalOpen(false)} destroyOnClose>
<Form form={inboundForm} layout="vertical">
<Form.Item name="batchNo" label="批次号" rules={[{ required: true, message: '请输入批次号' }]}>
<Input />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="quantity" label="数量(米)" rules={[{ required: true, message: '请输入数量' }]}>
<Input type="number" min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="rolls" label="匹数" rules={[{ required: true, message: '请输入匹数' }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="pricePerMeter" label="单价(每米)" rules={[{ required: true, message: '请输入单价' }]}>
<Input type="number" min={0} step="0.01" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</Space>
);
};
export default PlanDetailPage;

View File

@ -0,0 +1,15 @@
import { describe, vi, beforeEach } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Production / Plans / index page', () => {
beforeEach(() => {
vi.clearAllMocks();
});
smokeTest(Page);
});

View File

@ -0,0 +1,85 @@
import { PlusOutlined } from '@ant-design/icons';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { Button, Progress, Tag } from 'antd';
import { useRef } from 'react';
import { history } from '@umijs/max';
import { listProductionPlan } from '@/services/api';
import type { ProductionPlanInfo } from '@/types/api';
const STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '草稿', color: 'default' },
1: { text: '待生产', color: 'processing' },
2: { text: '生产中', color: 'warning' },
3: { text: '已完成', color: 'success' },
4: { text: '已取消', color: 'error' },
};
const PlansPage: React.FC = () => {
const actionRef = useRef<ActionType>();
const columns: ProColumns<ProductionPlanInfo>[] = [
{ title: '计划编号', dataIndex: 'planCode', copyable: true },
{ title: '产品名称', dataIndex: 'productName' },
{ title: '颜色', dataIndex: 'color', search: false },
{ title: '目标数量', dataIndex: 'targetQuantity', search: false },
{
title: '完成进度',
dataIndex: 'completedQuantity',
search: false,
render: (_, r) => {
const target = parseFloat(r.targetQuantity) || 1;
const completed = parseFloat(r.completedQuantity) || 0;
const pct = Math.min(Math.round((completed / target) * 100), 100);
return <Progress percent={pct} size="small" />;
},
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
valueEnum: Object.fromEntries(
Object.entries(STATUS_MAP).map(([k, v]) => [k, { text: v.text }]),
),
render: (_, r) => {
const s = STATUS_MAP[r.status] ?? STATUS_MAP[0];
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{ title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<a key="view" onClick={() => history.push(`/production/plans/${r.planId}`)}></a>,
],
},
];
return (
<ProTable<ProductionPlanInfo>
actionRef={actionRef}
headerTitle="生产计划"
rowKey="planId"
columns={columns}
request={async (params) => {
const res = await listProductionPlan({
page: params.current ?? 1,
pageSize: params.pageSize ?? 20,
status: params.status != null ? Number(params.status) : undefined,
productName: params.productName,
planCode: params.planCode,
});
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => history.push('/production/plans/new')}></Button>,
]}
onRow={(record) => ({
onClick: () => history.push(`/production/plans/${record.planId}`),
style: { cursor: 'pointer' },
})}
/>
);
};
export default PlansPage;

View File

@ -0,0 +1,15 @@
import { describe, vi, beforeEach } from 'vitest';
vi.mock('@umijs/max');
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './new';
describe('Production / Plans / new page', () => {
beforeEach(() => {
vi.clearAllMocks();
});
smokeTest(Page);
});

View File

@ -0,0 +1,208 @@
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Card, Col, DatePicker, Form, Input, InputNumber, Row, Select, Space, Table, Typography, message } from 'antd';
import { useEffect, useState } from 'react';
import { history } from '@umijs/max';
import { createProductionPlan, listProducts, listSuppliers } from '@/services/api';
import type { Product, Supplier } from '@/types/api';
const { Title } = Typography;
interface YarnRow {
key: number;
yarnName?: string;
ratio?: string;
amountPerMeter?: string;
}
let rowKey = 0;
const NewPlanPage: React.FC = () => {
const [form] = Form.useForm();
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [yarnRows, setYarnRows] = useState<YarnRow[]>([{ key: rowKey++ }]);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
listSuppliers({ page: 1, pageSize: 200, status: 1 }).then((res) => {
if (res?.code === 0) setSuppliers(res.data?.list || []);
});
listProducts({ page: 1, pageSize: 500 }).then((res) => {
if (res?.code === 0) setProducts(res.data?.list || []);
});
}, []);
const productMap = Object.fromEntries(products.map((p) => [p.productId, p]));
const handleProductChange = (productId: string) => {
const p = productMap[productId];
if (!p) return;
form.setFieldsValue({ productName: p.productName, color: p.color || '' });
};
const handleYarnField = (idx: number, field: keyof YarnRow, value: string) => {
setYarnRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [field]: value } : r)));
};
const addYarnRow = () => setYarnRows((prev) => [...prev, { key: rowKey++ }]);
const removeYarnRow = (idx: number) => setYarnRows((prev) => prev.filter((_, i) => i !== idx));
const handleSubmit = async () => {
let values: any;
try {
values = await form.validateFields();
} catch {
return;
}
setSubmitting(true);
try {
const yarnRatios = yarnRows
.filter((r) => r.yarnName && r.ratio)
.map((r) => ({ yarnName: r.yarnName!, ratio: r.ratio!, amountPerMeter: r.amountPerMeter || '0' }));
const res = await createProductionPlan({
...values,
startTime: values.startTime?.format?.('YYYY-MM-DD') || values.startTime,
yarnRatios,
});
if (res?.code !== 0) {
message.error(res?.msg || '创建失败');
return;
}
message.success('生产计划已创建');
history.push('/production/plans');
} catch {
message.error('创建失败');
} finally {
setSubmitting(false);
}
};
const yarnColumns = [
{
title: '纱线名称',
width: 180,
render: (_: unknown, _row: YarnRow, idx: number) => (
<Input value={yarnRows[idx].yarnName} onChange={(e) => handleYarnField(idx, 'yarnName', e.target.value)} placeholder="纱线名称" />
),
},
{
title: '配比 (%)',
width: 120,
render: (_: unknown, _row: YarnRow, idx: number) => (
<Input value={yarnRows[idx].ratio} onChange={(e) => handleYarnField(idx, 'ratio', e.target.value)} placeholder="如 30" />
),
},
{
title: '每米用量',
width: 120,
render: (_: unknown, _row: YarnRow, idx: number) => (
<Input value={yarnRows[idx].amountPerMeter} onChange={(e) => handleYarnField(idx, 'amountPerMeter', e.target.value)} placeholder="kg/m" />
),
},
{
title: '',
width: 48,
render: (_: unknown, _row: YarnRow, idx: number) => (
<Button type="text" danger icon={<DeleteOutlined />} disabled={yarnRows.length === 1} onClick={() => removeYarnRow(idx)} />
),
},
];
return (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Space>
<Button onClick={() => history.back()}></Button>
<Title level={4} style={{ margin: 0 }}></Title>
</Space>
<Card>
<Form form={form} layout="vertical">
<Row gutter={24}>
<Col span={8}>
<Form.Item name="productId" label="产品" rules={[{ required: true, message: '请选择产品' }]}>
<Select
showSearch
placeholder="选择产品"
optionFilterProp="label"
options={products.map((p) => ({ value: p.productId, label: `${p.productName}${p.color ? ` ${p.color}` : ''}` }))}
onChange={handleProductChange}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="productName" label="产品名称">
<Input />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="color" label="颜色">
<Input />
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={6}>
<Form.Item name="fabricCode" label="面料编号">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="colorCode" label="色号">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="targetQuantity" label="目标数量(米)" rules={[{ required: true, message: '请输入目标数量' }]}>
<Input type="number" min={0} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="yarnUsagePerMeter" label="每米用纱量">
<Input type="number" min={0} step="0.01" />
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={8}>
<Form.Item name="productionPrice" label="生产单价">
<Input type="number" min={0} step="0.01" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="supplierId" label="供应商(纺织厂)">
<Select
showSearch
placeholder="选择供应商"
optionFilterProp="label"
allowClear
options={suppliers.map((s) => ({ value: s.supplierId, label: s.supplierName }))}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="startTime" label="开工日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Card>
<Card title="纱线配比" extra={<Button icon={<PlusOutlined />} onClick={addYarnRow}>线</Button>}>
<Table rowKey="key" dataSource={yarnRows} columns={yarnColumns} pagination={false} size="small" />
</Card>
<Space>
<Button onClick={() => history.back()}></Button>
<Button type="primary" loading={submitting} onClick={handleSubmit}></Button>
</Space>
</Space>
);
};
export default NewPlanPage;

View File

@ -0,0 +1,28 @@
import { describe, vi, beforeEach } from 'vitest';
vi.mock('@umijs/max', () => ({
request: vi.fn(() => Promise.resolve({ code: 0, msg: 'ok', success: true, data: { list: [], total: 0 } })),
history: { push: vi.fn(), replace: vi.fn(), back: vi.fn(), go: vi.fn(), location: { pathname: '/' } },
useModel: vi.fn(() => ({ initialState: { currentUser: { name: 'tester', menuPaths: [] } }, setInitialState: vi.fn() })),
useParams: vi.fn(() => ({ shortCode: 'abc123' })),
useSearchParams: vi.fn(() => [new URLSearchParams(), vi.fn()]),
useNavigate: vi.fn(() => vi.fn()),
useLocation: vi.fn(() => ({ pathname: '/' })),
useAccess: vi.fn(() => ({})),
Link: ({ children }: any) => children,
NavLink: ({ children }: any) => children,
Access: ({ children }: any) => children,
Outlet: () => null,
}));
vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
describe('Production / Share / index page', () => {
beforeEach(() => {
vi.clearAllMocks();
});
smokeTest(Page);
});

View File

@ -0,0 +1,174 @@
import { Button, Card, Descriptions, Input, Result, Space, Spin, Tag, Typography, message } from 'antd';
import { useEffect, useState } from 'react';
import { useParams } from '@umijs/max';
import {
clickShareLink,
confirmShareLink,
getShareLinkPlanView,
rejectShareLink,
} from '@/services/api';
import type { ShareLinkPlanView } from '@/types/api';
const PLAN_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '草稿', color: 'default' },
1: { text: '待生产', color: 'processing' },
2: { text: '生产中', color: 'warning' },
3: { text: '已完成', color: 'success' },
4: { text: '已取消', color: 'error' },
};
const LINK_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '待处理', color: 'processing' },
1: { text: '已点击', color: 'cyan' },
2: { text: '已确认', color: 'success' },
3: { text: '已拒绝', color: 'error' },
4: { text: '已过期', color: 'default' },
};
const SharePage: React.FC = () => {
const { shortCode } = useParams<{ shortCode: string }>();
const [data, setData] = useState<ShareLinkPlanView | null>(null);
const [loading, setLoading] = useState(true);
const [rejectReason, setRejectReason] = useState('');
const [showReject, setShowReject] = useState(false);
const load = async () => {
if (!shortCode) return;
setLoading(true);
try {
// Record click
await clickShareLink({ shortCode }).catch(() => {});
const res = await getShareLinkPlanView(shortCode);
if (res?.code === 0) {
setData(res.data);
}
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [shortCode]);
const handleConfirm = async () => {
if (!shortCode) return;
try {
await confirmShareLink({ shortCode });
message.success('已确认接单');
load();
} catch {
message.error('操作失败,请登录后重试');
}
};
const handleReject = async () => {
if (!shortCode || !rejectReason.trim()) {
message.warning('请填写拒绝原因');
return;
}
try {
await rejectShareLink({ shortCode, rejectReason: rejectReason.trim() });
message.success('已拒绝');
setShowReject(false);
load();
} catch {
message.error('操作失败,请登录后重试');
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!data) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Result status="404" title="链接无效" subTitle="该分享链接不存在或已过期" />
</div>
);
}
const { plan, link } = data;
const hideSensitive = link.hideSensitive === 1;
const planStatus = PLAN_STATUS_MAP[plan.status] ?? PLAN_STATUS_MAP[0];
const linkStatus = LINK_STATUS_MAP[link.status] ?? LINK_STATUS_MAP[0];
const isPending = link.status === 0 || link.status === 1;
return (
<div style={{ maxWidth: 800, margin: '40px auto', padding: '0 16px' }}>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Typography.Title level={3}> {plan.planCode}</Typography.Title>
<Card>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="计划编号">{plan.planCode}</Descriptions.Item>
<Descriptions.Item label="产品名称">{plan.productName}</Descriptions.Item>
<Descriptions.Item label="颜色">{plan.color}</Descriptions.Item>
<Descriptions.Item label="面料编号">{plan.fabricCode}</Descriptions.Item>
<Descriptions.Item label="目标数量">{plan.targetQuantity} </Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={planStatus.color}>{planStatus.text}</Tag>
</Descriptions.Item>
{!hideSensitive && (
<>
<Descriptions.Item label="生产单价">¥ {plan.productionPrice}</Descriptions.Item>
<Descriptions.Item label="每米用纱量">{plan.yarnUsagePerMeter}</Descriptions.Item>
</>
)}
<Descriptions.Item label="开工日期">{plan.startTime}</Descriptions.Item>
{plan.remark && <Descriptions.Item label="备注" span={2}>{plan.remark}</Descriptions.Item>}
</Descriptions>
</Card>
{plan.yarnRatios && plan.yarnRatios.length > 0 && (
<Card title="纱线配比">
<Descriptions column={1} size="small">
{plan.yarnRatios.map((yr) => (
<Descriptions.Item key={yr.ratioId} label={yr.yarnName}>
{yr.ratio}%{!hideSensitive ? ` — 每米用量 ${yr.amountPerMeter}` : ''}
</Descriptions.Item>
))}
</Descriptions>
</Card>
)}
<Card>
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text>
: <Tag color={linkStatus.color}>{linkStatus.text}</Tag>
</Typography.Text>
{link.rejectReason && (
<Typography.Text type="danger">: {link.rejectReason}</Typography.Text>
)}
</Space>
</Card>
{isPending && (
<Space>
<Button type="primary" size="large" onClick={handleConfirm}></Button>
<Button danger size="large" onClick={() => setShowReject(!showReject)}></Button>
</Space>
)}
{showReject && (
<Card>
<Space direction="vertical" style={{ width: '100%' }}>
<Input.TextArea
rows={3}
placeholder="请填写拒绝原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
<Button danger onClick={handleReject}></Button>
</Space>
</Card>
)}
</Space>
</div>
);
};
export default SharePage;

View File

@ -103,3 +103,50 @@ export const getPurchaseStatsSummary = vi.fn(ok);
export const getPurchaseStatsBySupplier = vi.fn(ok); export const getPurchaseStatsBySupplier = vi.fn(ok);
export const getPurchaseStatsByProduct = vi.fn(ok); export const getPurchaseStatsByProduct = vi.fn(ok);
export const listInventoryLogs = vi.fn(ok); export const listInventoryLogs = vi.fn(ok);
// ── Production ───────────────────────────────────
export const createProductionPlan = vi.fn(ok);
export const listProductionPlan = vi.fn(ok);
export const getProductionPlan = vi.fn(() =>
Promise.resolve({
code: 0, msg: 'ok', success: true,
data: {
planId: 'plan-1', planCode: 'PP-001', productName: '测试产品', color: '红色',
fabricCode: 'FC-01', colorCode: 'CC-01', targetQuantity: '1000', completedQuantity: '0',
yarnUsagePerMeter: '0.5', productionPrice: '10', supplierId: 's1', status: 0,
remark: '', startTime: '2024-01-01', creator: 'admin', operator: 'admin',
createdAt: '2024-01-01', updatedAt: '2024-01-01', tenantId: 't1',
yarnRatios: [], processSteps: [], inboundRecords: [],
},
}),
);
export const confirmPlan = vi.fn(ok);
export const createShareLink = vi.fn(ok);
export const completeStep = vi.fn(ok);
export const createInbound = vi.fn(ok);
export const listInbound = vi.fn(ok);
export const getShareLinkPlanView = vi.fn(() =>
Promise.resolve({
code: 0, msg: 'ok', success: true,
data: {
plan: {
planId: 'plan-1', planCode: 'PP-001', productName: '测试产品', color: '红色',
fabricCode: 'FC-01', colorCode: 'CC-01', targetQuantity: '1000', completedQuantity: '0',
yarnUsagePerMeter: '0.5', productionPrice: '10', supplierId: 's1', status: 1,
remark: '', startTime: '2024-01-01', creator: 'admin', operator: 'admin',
createdAt: '2024-01-01', updatedAt: '2024-01-01', tenantId: 't1',
yarnRatios: [], processSteps: [], inboundRecords: [],
},
link: {
linkId: 'link-1', tenantId: 't1', planId: 'plan-1', shortCode: 'abc123',
token: 'tok', factoryType: 'weaving', hideSensitive: 0, status: 0,
clickCount: 1, clickedAt: '', respondedAt: '', rejectReason: '',
expiresAt: '2024-12-31', createdBy: 'admin', createdAt: '2024-01-01', updatedAt: '2024-01-01',
},
},
}),
);
export const clickShareLink = vi.fn(ok);
export const confirmShareLink = vi.fn(ok);
export const rejectShareLink = vi.fn(ok);

View File

@ -4,6 +4,7 @@ import type {
BoltInfo, BoltInfo,
ColorDetailItem, ColorDetailItem,
CurrentUser, CurrentUser,
InboundRecordInfo,
InventoryLog, InventoryLog,
LoginParams, LoginParams,
LoginResult, LoginResult,
@ -14,6 +15,7 @@ import type {
PanInfo, PanInfo,
PanInput, PanInput,
Product, Product,
ProductionPlanInfo,
ProductSummaryItem, ProductSummaryItem,
ProductTree, ProductTree,
PurchaseOrder, PurchaseOrder,
@ -25,6 +27,8 @@ import type {
Relation, Relation,
RelationHistory, RelationHistory,
Role, Role,
ShareLinkInfo,
ShareLinkPlanView,
StockAdjust, StockAdjust,
StockCheck, StockCheck,
StockGroup, StockGroup,
@ -343,3 +347,45 @@ export const getPurchaseStatsByProduct = (params: { startDate?: string; endDate?
export const listInventoryLogs = (params: PaginationParams & { productId?: string; changeType?: number; startDate?: string; endDate?: string }) => export const listInventoryLogs = (params: PaginationParams & { productId?: string; changeType?: number; startDate?: string; endDate?: string }) =>
request<ApiResponse<PaginatedData<InventoryLog>>>('/api/v1/inventory/logs', { method: 'GET', params }); request<ApiResponse<PaginatedData<InventoryLog>>>('/api/v1/inventory/logs', { method: 'GET', params });
// ── Production Plans ─────────────────────────────
export const createProductionPlan = (data: any) =>
request<ApiResponse<{ id: string }>>('/api/v1/production/plan', { method: 'POST', data });
export const listProductionPlan = (params: { page: number; pageSize: number; status?: number; productName?: string; planCode?: string }) =>
request<ApiResponse<{ total: number; list: ProductionPlanInfo[] }>>('/api/v1/production/plan', { method: 'GET', params });
export const getProductionPlan = (planId: string) =>
request<ApiResponse<ProductionPlanInfo>>(`/api/v1/production/plan/${planId}`, { method: 'GET' });
export const confirmPlan = (data: { planId: string }) =>
request<ApiResponse<null>>('/api/v1/production/plan/confirm', { method: 'POST', data });
export const createShareLink = (data: { planId: string; expiresHours?: number; hideSensitive?: number }) =>
request<ApiResponse<ShareLinkInfo>>('/api/v1/production/share', { method: 'POST', data });
export const completeStep = (data: { planId: string; stepType: string }) =>
request<ApiResponse<null>>('/api/v1/production/process/step', { method: 'POST', data });
export const createInbound = (data: { planId: string; batchNo: string; quantity: string; rolls: number; pricePerMeter: string; remark?: string }) =>
request<ApiResponse<{ id: string }>>('/api/v1/production/inbound', { method: 'POST', data });
export const listInbound = (params: { planId: string; page: number; pageSize: number }) =>
request<ApiResponse<{ total: number; list: InboundRecordInfo[]; totalQuantity: number; totalRolls: number }>>('/api/v1/production/inbound', { method: 'GET', params });
// ── Public Share Link (no JWT) ───────────────────
export const getShareLinkPlanView = (shortCode: string) =>
request<ApiResponse<ShareLinkPlanView>>(`/api/v1/public/production/share/${shortCode}`, { method: 'GET' });
export const clickShareLink = (data: { shortCode: string }) =>
request<ApiResponse<null>>('/api/v1/public/production/share/click', { method: 'POST', data });
// ── Share Link Actions (JWT required) ────────────
export const confirmShareLink = (data: { shortCode: string }) =>
request<ApiResponse<null>>('/api/v1/production/share/confirm', { method: 'POST', data });
export const rejectShareLink = (data: { shortCode: string; rejectReason: string }) =>
request<ApiResponse<null>>('/api/v1/production/share/reject', { method: 'POST', data });

View File

@ -399,3 +399,92 @@ export interface PurchaseStatsByProductItem {
avgUnitPrice: string; avgUnitPrice: string;
totalAmount: string; totalAmount: string;
} }
// ── Production ───────────────────────────────────
export interface ProductionPlanInfo {
planId: string;
tenantId: string;
planCode: string;
productId: string;
productName: string;
color: string;
fabricCode: string;
colorCode: string;
targetQuantity: string;
completedQuantity: string;
yarnUsagePerMeter: string;
productionPrice: string;
supplierId: string;
status: number;
remark: string;
startTime: string;
creator: string;
operator: string;
createdAt: string;
updatedAt: string;
yarnRatios: YarnRatioInfo[];
processSteps: ProcessStepInfo[];
inboundRecords: InboundRecordInfo[];
}
export interface YarnRatioInfo {
ratioId: string;
planId: string;
yarnName: string;
ratio: string;
amountPerMeter: string;
totalAmount: string;
createdAt: string;
}
export interface ProcessStepInfo {
stepId: string;
planId: string;
stepType: string;
stepOrder: number;
status: number;
operatorId: string;
completedAt: string;
remark: string;
createdAt: string;
updatedAt: string;
}
export interface InboundRecordInfo {
recordId: string;
tenantId: string;
planId: string;
productId: string;
batchNo: string;
quantity: string;
rolls: number;
pricePerMeter: string;
operatorId: string;
remark: string;
createdAt: string;
}
export interface ShareLinkInfo {
linkId: string;
tenantId: string;
planId: string;
shortCode: string;
token: string;
factoryType: string;
hideSensitive: number;
status: number;
clickCount: number;
clickedAt: string;
respondedAt: string;
rejectReason: string;
expiresAt: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface ShareLinkPlanView {
plan: ProductionPlanInfo;
link: ShareLinkInfo;
}