From b66d113ef878ec508560fcd52b830e95c131ea5d Mon Sep 17 00:00:00 2001 From: Chenwei Jiang Date: Sat, 4 Jul 2026 02:29:21 +0000 Subject: [PATCH] feat: add production management frontend with CI/CD pipeline --- .dockerignore | 9 + .gitignore | 3 + .gitlab-ci.yml | 46 +++ .umirc.ts | 17 ++ Dockerfile | 21 ++ nginx.conf | 31 ++ src/pages/Inventory/Logs/index.tsx | 44 +++ src/pages/Production/Plans/detail.test.tsx | 28 ++ src/pages/Production/Plans/detail.tsx | 331 +++++++++++++++++++++ src/pages/Production/Plans/index.test.tsx | 15 + src/pages/Production/Plans/index.tsx | 85 ++++++ src/pages/Production/Plans/new.test.tsx | 15 + src/pages/Production/Plans/new.tsx | 208 +++++++++++++ src/pages/Production/Share/index.test.tsx | 28 ++ src/pages/Production/Share/index.tsx | 174 +++++++++++ src/services/__mocks__/api.ts | 47 +++ src/services/api.ts | 46 +++ src/types/api.ts | 89 ++++++ 18 files changed, 1237 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitlab-ci.yml create mode 100644 Dockerfile create mode 100644 nginx.conf create mode 100644 src/pages/Inventory/Logs/index.tsx create mode 100644 src/pages/Production/Plans/detail.test.tsx create mode 100644 src/pages/Production/Plans/detail.tsx create mode 100644 src/pages/Production/Plans/index.test.tsx create mode 100644 src/pages/Production/Plans/index.tsx create mode 100644 src/pages/Production/Plans/new.test.tsx create mode 100644 src/pages/Production/Plans/new.tsx create mode 100644 src/pages/Production/Share/index.test.tsx create mode 100644 src/pages/Production/Share/index.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..52fa06c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.git +.gitignore +.superpowers +__mocks__ +test +*.md +.umirc.local.ts diff --git a/.gitignore b/.gitignore index dd33e05..2955145 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ # Created by https://www.toptal.com/developers/gitignore/api/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 ### # General .DS_Store diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..6ff863b --- /dev/null +++ b/.gitlab-ci.yml @@ -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 diff --git a/.umirc.ts b/.umirc.ts index 611a2d2..c1ee945 100644 --- a/.umirc.ts +++ b/.umirc.ts @@ -83,6 +83,23 @@ export default defineConfig({ { 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: '系统管理', path: '/system', diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4f9788b --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..a189f3b --- /dev/null +++ b/nginx.conf @@ -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"; + } +} diff --git a/src/pages/Inventory/Logs/index.tsx b/src/pages/Inventory/Logs/index.tsx new file mode 100644 index 0000000..89608e8 --- /dev/null +++ b/src/pages/Inventory/Logs/index.tsx @@ -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[] = [ + { 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 = () => ( + + headerTitle="库存变动记录" + rowKey="id" + columns={columns} + request={async () => ({ + data: [], + total: 0, + success: true, + })} + search={{ labelWidth: 'auto' }} + pagination={{ pageSize: 20 }} + /> +); + +export default LogsPage; diff --git a/src/pages/Production/Plans/detail.test.tsx b/src/pages/Production/Plans/detail.test.tsx new file mode 100644 index 0000000..6caa9f9 --- /dev/null +++ b/src/pages/Production/Plans/detail.test.tsx @@ -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); +}); diff --git a/src/pages/Production/Plans/detail.tsx b/src/pages/Production/Plans/detail.tsx new file mode 100644 index 0000000..d67d3ca --- /dev/null +++ b/src/pages/Production/Plans/detail.tsx @@ -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 = { + 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 = { + confirm: '计划确认', + yarn_purchase: '纱线采购', + dyeing: '染色', + machine_start: '上机生产', + fabric_warehouse: '成品入库', +}; + +const STEP_STATUS_MAP: Record = { + 0: 'wait', + 1: 'process', + 2: 'finish', +}; + +const PlanDetailPage: React.FC = () => { + const { planId } = useParams<{ planId: string }>(); + const [plan, setPlan] = useState(null); + const [inboundRecords, setInboundRecords] = useState([]); + const [inboundTotal, setInboundTotal] = useState(0); + const [inboundModalOpen, setInboundModalOpen] = useState(false); + const [shareLink, setShareLink] = useState(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 ( + + + + + 生产计划详情 — {plan.planCode} + + {statusInfo.text} + + + {plan.status === 0 && ( + + + + + )} + {plan.status === 1 && ( + + )} + + + + + {plan.planCode} + {plan.productName} + {plan.color} + {plan.fabricCode} + {plan.colorCode} + {plan.targetQuantity} 米 + {plan.completedQuantity} 米 + {plan.yarnUsagePerMeter} + ¥ {plan.productionPrice} + {plan.startTime} + {plan.creator} + {plan.createdAt} + {plan.remark && {plan.remark}} + + + + {plan.yarnRatios && plan.yarnRatios.length > 0 && ( + + + + )} + + ), + }, + { + key: 'process', + label: '生产流程', + children: ( + + +
+ {STEP_TYPES.map((type) => { + const step = stepsByType[type]; + const isCompleted = step?.status === 2; + const canComplete = plan.status >= 1 && !isCompleted; + return canComplete ? ( + + ) : null; + })} +
+
+ ), + }, + { + key: 'inbound', + label: '入库记录', + children: ( + } onClick={() => { inboundForm.resetFields(); setInboundModalOpen(true); }}>新增入库 + } + > + + 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' }, + ]} + /> + + ), + }, + { + key: 'share', + label: '分享管理', + children: ( + + + + {shareLink && ( + + {shareLink.shortCode} + + + {shareLink.status === 0 ? '待处理' : + shareLink.status === 1 ? '已点击' : + shareLink.status === 2 ? '已确认' : + shareLink.status === 3 ? '已拒绝' : + shareLink.status === 4 ? '已过期' : '未知'} + + + {shareLink.expiresAt} + {shareLink.clickCount} + + + {`${window.location.origin}/share/production/${shareLink.shortCode}`} + + + + + )} + + + ), + }, + ]} + /> + + setInboundModalOpen(false)} destroyOnClose> +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + ); +}; + +export default PlanDetailPage; diff --git a/src/pages/Production/Plans/index.test.tsx b/src/pages/Production/Plans/index.test.tsx new file mode 100644 index 0000000..b3d423b --- /dev/null +++ b/src/pages/Production/Plans/index.test.tsx @@ -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); +}); diff --git a/src/pages/Production/Plans/index.tsx b/src/pages/Production/Plans/index.tsx new file mode 100644 index 0000000..b5ff07e --- /dev/null +++ b/src/pages/Production/Plans/index.tsx @@ -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 = { + 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(); + + const columns: ProColumns[] = [ + { 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 ; + }, + }, + { + 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 {s.text}; + }, + }, + { title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + history.push(`/production/plans/${r.planId}`)}>详情, + ], + }, + ]; + + return ( + + 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={() => [ + , + ]} + onRow={(record) => ({ + onClick: () => history.push(`/production/plans/${record.planId}`), + style: { cursor: 'pointer' }, + })} + /> + ); +}; + +export default PlansPage; diff --git a/src/pages/Production/Plans/new.test.tsx b/src/pages/Production/Plans/new.test.tsx new file mode 100644 index 0000000..6896e42 --- /dev/null +++ b/src/pages/Production/Plans/new.test.tsx @@ -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); +}); diff --git a/src/pages/Production/Plans/new.tsx b/src/pages/Production/Plans/new.tsx new file mode 100644 index 0000000..6ebc2e3 --- /dev/null +++ b/src/pages/Production/Plans/new.tsx @@ -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([]); + const [products, setProducts] = useState([]); + const [yarnRows, setYarnRows] = useState([{ 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) => ( + handleYarnField(idx, 'yarnName', e.target.value)} placeholder="纱线名称" /> + ), + }, + { + title: '配比 (%)', + width: 120, + render: (_: unknown, _row: YarnRow, idx: number) => ( + handleYarnField(idx, 'ratio', e.target.value)} placeholder="如 30" /> + ), + }, + { + title: '每米用量', + width: 120, + render: (_: unknown, _row: YarnRow, idx: number) => ( + handleYarnField(idx, 'amountPerMeter', e.target.value)} placeholder="kg/m" /> + ), + }, + { + title: '', + width: 48, + render: (_: unknown, _row: YarnRow, idx: number) => ( + + 新建生产计划 + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + ); +}; + +export default NewPlanPage; diff --git a/src/pages/Production/Share/index.test.tsx b/src/pages/Production/Share/index.test.tsx new file mode 100644 index 0000000..fbaeb7e --- /dev/null +++ b/src/pages/Production/Share/index.test.tsx @@ -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); +}); diff --git a/src/pages/Production/Share/index.tsx b/src/pages/Production/Share/index.tsx new file mode 100644 index 0000000..5b15250 --- /dev/null +++ b/src/pages/Production/Share/index.tsx @@ -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 = { + 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 = { + 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(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 ( +
+ +
+ ); + } + + if (!data) { + return ( +
+ +
+ ); + } + + 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 ( +
+ + 生产计划 — {plan.planCode} + + + + {plan.planCode} + {plan.productName} + {plan.color} + {plan.fabricCode} + {plan.targetQuantity} 米 + + {planStatus.text} + + {!hideSensitive && ( + <> + ¥ {plan.productionPrice} + {plan.yarnUsagePerMeter} + + )} + {plan.startTime} + {plan.remark && {plan.remark}} + + + + {plan.yarnRatios && plan.yarnRatios.length > 0 && ( + + + {plan.yarnRatios.map((yr) => ( + + 配比 {yr.ratio}%{!hideSensitive ? ` — 每米用量 ${yr.amountPerMeter}` : ''} + + ))} + + + )} + + + + + 当前状态: {linkStatus.text} + + {link.rejectReason && ( + 拒绝原因: {link.rejectReason} + )} + + + + {isPending && ( + + + + + )} + + {showReject && ( + + + setRejectReason(e.target.value)} + /> + + + + )} + +
+ ); +}; + +export default SharePage; diff --git a/src/services/__mocks__/api.ts b/src/services/__mocks__/api.ts index ed26bd4..5a3d6c6 100644 --- a/src/services/__mocks__/api.ts +++ b/src/services/__mocks__/api.ts @@ -103,3 +103,50 @@ export const getPurchaseStatsSummary = vi.fn(ok); export const getPurchaseStatsBySupplier = vi.fn(ok); export const getPurchaseStatsByProduct = 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); diff --git a/src/services/api.ts b/src/services/api.ts index 78d23b3..9c7702c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -4,6 +4,7 @@ import type { BoltInfo, ColorDetailItem, CurrentUser, + InboundRecordInfo, InventoryLog, LoginParams, LoginResult, @@ -14,6 +15,7 @@ import type { PanInfo, PanInput, Product, + ProductionPlanInfo, ProductSummaryItem, ProductTree, PurchaseOrder, @@ -25,6 +27,8 @@ import type { Relation, RelationHistory, Role, + ShareLinkInfo, + ShareLinkPlanView, StockAdjust, StockCheck, 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 }) => request>>('/api/v1/inventory/logs', { method: 'GET', params }); + +// ── Production Plans ───────────────────────────── + +export const createProductionPlan = (data: any) => + request>('/api/v1/production/plan', { method: 'POST', data }); + +export const listProductionPlan = (params: { page: number; pageSize: number; status?: number; productName?: string; planCode?: string }) => + request>('/api/v1/production/plan', { method: 'GET', params }); + +export const getProductionPlan = (planId: string) => + request>(`/api/v1/production/plan/${planId}`, { method: 'GET' }); + +export const confirmPlan = (data: { planId: string }) => + request>('/api/v1/production/plan/confirm', { method: 'POST', data }); + +export const createShareLink = (data: { planId: string; expiresHours?: number; hideSensitive?: number }) => + request>('/api/v1/production/share', { method: 'POST', data }); + +export const completeStep = (data: { planId: string; stepType: string }) => + request>('/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>('/api/v1/production/inbound', { method: 'POST', data }); + +export const listInbound = (params: { planId: string; page: number; pageSize: number }) => + request>('/api/v1/production/inbound', { method: 'GET', params }); + +// ── Public Share Link (no JWT) ─────────────────── + +export const getShareLinkPlanView = (shortCode: string) => + request>(`/api/v1/public/production/share/${shortCode}`, { method: 'GET' }); + +export const clickShareLink = (data: { shortCode: string }) => + request>('/api/v1/public/production/share/click', { method: 'POST', data }); + +// ── Share Link Actions (JWT required) ──────────── + +export const confirmShareLink = (data: { shortCode: string }) => + request>('/api/v1/production/share/confirm', { method: 'POST', data }); + +export const rejectShareLink = (data: { shortCode: string; rejectReason: string }) => + request>('/api/v1/production/share/reject', { method: 'POST', data }); diff --git a/src/types/api.ts b/src/types/api.ts index 997cd17..e93d269 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -399,3 +399,92 @@ export interface PurchaseStatsByProductItem { avgUnitPrice: 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; +}