Compare commits

...

12 Commits

Author SHA1 Message Date
kae
baf3d4fc2d fix: hash frontend build assets 2026-07-06 04:02:03 +09:00
kae
b72f1d7670 feat(deploy): parameterize registry and mirror via ARG for cross-region builds
Convert hardcoded BASE_REGISTRY, APK_MIRROR, and NPM_REGISTRY values to
ARG in both builder and runtime stages. CI defaults (Harbor + Aliyun +
npmmirror) remain in Dockerfile ARG defaults; overseas builds override
via --build-arg.
2026-07-06 00:20:40 +09:00
kae_mihara
066724318a
feat:improve product yarn ratio workflow
## Summary

- Reworked the product creation modal around warp/weft yarn ratio tables.
- Added yarn selection, create-yarn entry points, ratio validation feedback, and ratio-based weight calculation.
- Updated frontend service/types coverage and tests for the product workflow.

## Why

The product creation flow previously did not guide users through structured warp/weft yarn ratios, making it easy to enter incomplete or invalid ratio data. The new flow makes the yarn composition explicit and blocks invalid product submission.

## Impact

Users can select existing yarns or create yarns while adding a product, edit ratio values per row, and calculate batch weights once both yarn sides are valid.

## Validation

- `pnpm test`
- `pnpm build`
2026-07-05 07:57:33 +09:00
kae_mihara
9e8179ddcd
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-05 07:56:39 +09:00
kae_mihara
5d5bc0742a
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-05 07:56:24 +09:00
kae_mihara
bd42ff8a52
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-05 07:55:59 +09:00
kae_mihara
ffd6354e88
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-05 07:54:50 +09:00
kae_mihara
219d308d1a
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-05 07:54:19 +09:00
kae_mihara
48ec6ffc18
Merge branch 'main' into feat/a-better-product-table 2026-07-05 07:47:38 +09:00
kae
cf94cb3b88 feat: improve product yarn ratio workflow 2026-07-05 07:33:09 +09:00
Chenwei Jiang
e23f94d105 Merge branch 'feat/production-plan-collaboration' into 'main'
feat: add production management frontend with CI/CD pipeline

See merge request kihaneseifu/muyu-portal!1
2026-07-04 02:29:22 +00:00
Chenwei Jiang
b66d113ef8 feat: add production management frontend with CI/CD pipeline 2026-07-04 02:29:21 +00:00
20 changed files with 1694 additions and 41 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
# 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

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

@ -1,6 +1,7 @@
import { defineConfig } from '@umijs/max';
export default defineConfig({
hash: true,
antd: {},
access: {},
model: {},
@ -83,6 +84,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',

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
ARG BASE_REGISTRY=harbor-in-k3s.cheverjohn.me/library
FROM ${BASE_REGISTRY}/node:20-alpine AS builder
ARG APK_MIRROR=mirrors.aliyun.com
ARG NPM_REGISTRY=https://registry.npmmirror.com
RUN [ -n "$APK_MIRROR" ] && sed -i "s|dl-cdn.alpinelinux.org|${APK_MIRROR}|g" /etc/apk/repositories || true
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --registry=${NPM_REGISTRY}
COPY . .
RUN pnpm build
FROM ${BASE_REGISTRY}/nginx:1.27-alpine
ARG APK_MIRROR=mirrors.aliyun.com
RUN [ -n "$APK_MIRROR" ] && sed -i "s|dl-cdn.alpinelinux.org|${APK_MIRROR}|g" /etc/apk/repositories || true
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

@ -7,7 +7,7 @@ vi.mock('@/services/api');
import { smokeTest } from '../../../../test/smoke';
import Page from './index';
import { createProduct, getProductSummary } from '@/services/api';
import { createProduct, getProductSummary, listYarns } from '@/services/api';
const clickDialogSubmit = async (user: ReturnType<typeof userEvent.setup>, dialog: HTMLElement) => {
const submitBtn = within(dialog).getByRole('button', { name: /确\s*定|确\s*认|提交|OK/i });
@ -68,6 +68,104 @@ describe('Inventory / Products page', () => {
});
});
it('renders yarn ratio tables and blocks weight calculation until both sides are complete', async () => {
const user = userEvent.setup();
render(<Page />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await user.click(screen.getByRole('button', { name: /新增产品/ }));
await waitFor(() => screen.getByRole('dialog'));
const dialog = screen.getByRole('dialog');
expect(within(dialog).getAllByText('纱线名称/纱线颜色')).toHaveLength(2);
expect(within(dialog).getAllByText('使用配比(合计 10')).toHaveLength(2);
expect(within(dialog).getAllByRole('button', { name: '根据纱线配比计算' })).toHaveLength(1);
await user.click(within(dialog).getByRole('button', { name: '根据纱线配比计算' }));
expect(await within(dialog).findByText('请先添加经线和纬线纱线,并确保两边配比合计均为 10')).toBeInTheDocument();
});
it('submits selected yarn ratio rows as initial batch yarnRatio', async () => {
vi.mocked(listYarns).mockResolvedValue({
code: 0,
msg: 'ok',
data: {
total: 1,
list: [
{ yarnId: 'yarn-1', yarnName: 'A纱', color: '红', weightGM: '20', supplierId: 'supplier-1' },
],
},
} as any);
const user = userEvent.setup();
render(<Page />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await user.click(screen.getByRole('button', { name: /新增产品/ }));
await waitFor(() => screen.getByRole('dialog'));
const dialog = screen.getByRole('dialog');
await user.type(within(dialog).getByRole('textbox', { name: /产品名称/ }), '测试产品');
await waitFor(() => expect(vi.mocked(listYarns)).toHaveBeenCalled());
await user.click(within(dialog).getByRole('combobox', { name: '添加经线纱线' }));
await user.click(await screen.findByText('A纱 / 红'));
await clickDialogSubmit(user, dialog);
await waitFor(() => {
expect(vi.mocked(createProduct)).toHaveBeenCalled();
});
const payload = vi.mocked(createProduct).mock.calls.at(-1)?.[0] as any;
const yarnRatio = JSON.parse(payload.initialBatch.yarnRatio);
expect(yarnRatio.warp).toEqual([
{ yarn_id: 'yarn-1', yarn_name: 'A纱', color: '红', ratio: 10 },
]);
expect(yarnRatio.weft).toEqual([]);
});
it('rejects product creation when an existing yarn side does not add up to ten', async () => {
vi.mocked(listYarns).mockResolvedValue({
code: 0,
msg: 'ok',
data: {
total: 1,
list: [
{ yarnId: 'yarn-1', yarnName: 'A纱', color: '红', weightGM: '20', supplierId: 'supplier-1' },
],
},
} as any);
const user = userEvent.setup();
render(<Page />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await user.click(screen.getByRole('button', { name: /新增产品/ }));
await waitFor(() => screen.getByRole('dialog'));
const dialog = screen.getByRole('dialog');
await user.type(within(dialog).getByRole('textbox', { name: /产品名称/ }), '测试产品');
await waitFor(() => expect(vi.mocked(listYarns)).toHaveBeenCalled());
await user.click(within(dialog).getByRole('combobox', { name: '添加经线纱线' }));
await user.click(await screen.findByText('A纱 / 红'));
const ratioInput = within(dialog).getByRole('spinbutton', { name: '经线A纱使用配比' });
await user.click(ratioInput);
await user.keyboard('{Control>}a{/Control}9');
await clickDialogSubmit(user, dialog);
await waitFor(() => {
expect(screen.getAllByText('经线配比合计需为 10当前为 9').length).toBeGreaterThan(0);
});
expect(vi.mocked(createProduct)).not.toHaveBeenCalled();
});
it('renders Excel export button', async () => {
render(<Page />);
await act(async () => {

View File

@ -1,6 +1,6 @@
import { PlusOutlined, UploadOutlined, DownloadOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { ActionType, ModalForm, ProColumns, ProFormSelect, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components';
import { Button, Card, Descriptions, Drawer, Input, InputNumber, List, message, Popconfirm, Segmented, Select, Space, Tag } from 'antd';
import { Alert, Button, Card, Descriptions, Drawer, Form, Input, InputNumber, List, message, Popconfirm, Segmented, Select, Space, Table, Tag } from 'antd';
import { useRef, useState, useCallback } from 'react';
import {
getProductSummary,
@ -19,12 +19,37 @@ import {
updateProductBatch,
deleteProductBatch,
} from '@/services/api';
import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput, ProductBatchInfo } from '@/types/api';
import type { ProductSummaryItem, ColorDetailItem, ProductTree, PanInput, ProductBatchInfo, YarnInfo } from '@/types/api';
type ViewLevel = 'summary' | 'colors' | 'tree';
type YarnSide = 'warp' | 'weft';
interface YarnRatioRow {
key: string;
yarnId: string;
yarnName: string;
yarnColor: string;
weightGM: string;
ratio: number;
}
const CREATE_YARN_OPTION = '__create_yarn__';
const ratioSideLabels: Record<YarnSide, string> = {
warp: '经线',
weft: '纬线',
};
const prominentErrorTextStyle = { color: '#ff1f1f', fontWeight: 600 };
const getRatioTotal = (rows: YarnRatioRow[]) => rows.reduce((sum, row) => sum + Number(row.ratio || 0), 0);
const formatWeight = (value: number) => {
const fixed = value.toFixed(4);
return fixed.replace(/\.?0+$/, '');
};
const ProductsPage: React.FC = () => {
const summaryRef = useRef<ActionType>();
const [createForm] = Form.useForm();
const [viewLevel, setViewLevel] = useState<ViewLevel>('summary');
const [selectedProductName, setSelectedProductName] = useState('');
const [selectedProductId, setSelectedProductId] = useState('');
@ -38,6 +63,12 @@ const ProductsPage: React.FC = () => {
const [batchOpen, setBatchOpen] = useState(false);
const [currentBatch, setCurrentBatch] = useState<ProductBatchInfo | null>(null);
const [yarnOpen, setYarnOpen] = useState(false);
const [yarnCreateTarget, setYarnCreateTarget] = useState<YarnSide | null>(null);
const [yarnOptions, setYarnOptions] = useState<YarnInfo[]>([]);
const [warpYarns, setWarpYarns] = useState<YarnRatioRow[]>([]);
const [weftYarns, setWeftYarns] = useState<YarnRatioRow[]>([]);
const [ratioSubmitError, setRatioSubmitError] = useState('');
const [weightCalcError, setWeightCalcError] = useState('');
const [unit, setUnit] = useState<'cm' | 'in'>('cm');
const formatCmSpec = (spec?: string) => {
@ -73,6 +104,257 @@ const ProductsPage: React.FC = () => {
}
}, []);
const loadYarnOptions = useCallback(async (keyWords?: string) => {
const res = await listYarns({ page: 1, pageSize: 50, yarnName: keyWords, status: 1 });
if (res?.code === 0) {
setYarnOptions(res.data?.list || []);
}
}, []);
const resetCreateProductState = useCallback(() => {
createForm.resetFields();
setWarpYarns([]);
setWeftYarns([]);
setRatioSubmitError('');
setWeightCalcError('');
setYarnCreateTarget(null);
}, [createForm]);
const handleCreateOpenChange = useCallback((open: boolean) => {
setCreateOpen(open);
if (open) {
resetCreateProductState();
loadYarnOptions();
} else {
resetCreateProductState();
}
}, [loadYarnOptions, resetCreateProductState]);
const getYarnRows = useCallback((side: YarnSide) => (
side === 'warp' ? warpYarns : weftYarns
), [warpYarns, weftYarns]);
const setYarnRows = useCallback((side: YarnSide, updater: (rows: YarnRatioRow[]) => YarnRatioRow[]) => {
const wrappedUpdater = (rows: YarnRatioRow[]) => updater(rows);
if (side === 'warp') {
setWarpYarns(wrappedUpdater);
return;
}
setWeftYarns(wrappedUpdater);
}, []);
const clearRatioFeedback = useCallback(() => {
setRatioSubmitError('');
setWeightCalcError('');
}, []);
const appendYarnRatioRow = useCallback((side: YarnSide, yarn: YarnInfo) => {
setYarnRows(side, (rows) => {
if (rows.some((row) => row.yarnId === yarn.yarnId)) {
return rows;
}
const remaining = 10 - getRatioTotal(rows);
const ratio = remaining > 0 ? Math.min(10, remaining) : 1;
return [
...rows,
{
key: `${side}-${yarn.yarnId}-${Date.now()}`,
yarnId: yarn.yarnId,
yarnName: yarn.yarnName,
yarnColor: yarn.color || '未设置颜色',
weightGM: yarn.weightGM,
ratio,
},
];
});
clearRatioFeedback();
}, [clearRatioFeedback, setYarnRows]);
const handleSelectYarn = (side: YarnSide, value: string) => {
if (value === CREATE_YARN_OPTION) {
setYarnCreateTarget(side);
setYarnOpen(true);
return;
}
const yarn = yarnOptions.find((item) => item.yarnId === value);
if (yarn) {
appendYarnRatioRow(side, yarn);
}
};
const updateYarnRatio = (side: YarnSide, key: string, value: number | null) => {
const nextRatio = Math.max(1, Math.min(10, Math.round(Number(value || 1))));
setYarnRows(side, (rows) => rows.map((row) => (
row.key === key ? { ...row, ratio: nextRatio } : row
)));
clearRatioFeedback();
};
const removeYarnRatioRow = (side: YarnSide, key: string) => {
setYarnRows(side, (rows) => rows.filter((row) => row.key !== key));
clearRatioFeedback();
};
const getRatioError = (side: YarnSide, rows: YarnRatioRow[]) => {
if (rows.length === 0) {
return '';
}
if (rows.some((row) => !row.yarnId)) {
return `${ratioSideLabels[side]}纱线不能为空`;
}
if (rows.some((row) => !Number.isInteger(row.ratio) || row.ratio < 1 || row.ratio > 10)) {
return `${ratioSideLabels[side]}使用配比必须为 1-10 的整数`;
}
const total = getRatioTotal(rows);
if (total !== 10) {
return `${ratioSideLabels[side]}配比合计需为 10当前为 ${total}`;
}
return '';
};
const getRatioErrors = () => (
[
getRatioError('warp', warpYarns),
getRatioError('weft', weftYarns),
].filter(Boolean)
);
const serializeYarnRatio = () => {
if (warpYarns.length === 0 && weftYarns.length === 0) {
return '';
}
return JSON.stringify({
warp: warpYarns.map((row) => ({
yarn_id: row.yarnId,
yarn_name: row.yarnName,
color: row.yarnColor,
ratio: row.ratio,
})),
weft: weftYarns.map((row) => ({
yarn_id: row.yarnId,
yarn_name: row.yarnName,
color: row.yarnColor,
ratio: row.ratio,
})),
});
};
const calculateSideWeight = (rows: YarnRatioRow[]) => rows.reduce((sum, row) => {
const weight = Number(row.weightGM || 0);
return sum + (Number.isFinite(weight) ? weight * row.ratio / 10 : 0);
}, 0);
const calculateBatchWeight = () => {
const ratioErrors = getRatioErrors();
if (warpYarns.length === 0 || weftYarns.length === 0) {
setWeightCalcError('请先添加经线和纬线纱线,并确保两边配比合计均为 10');
return;
}
if (ratioErrors.length > 0) {
setWeightCalcError(ratioErrors.join(''));
return;
}
const initialBatch = createForm.getFieldValue('initialBatch') || {};
createForm.setFieldsValue({
initialBatch: {
...initialBatch,
warpWeightGM: formatWeight(calculateSideWeight(warpYarns)),
weftWeightGM: formatWeight(calculateSideWeight(weftYarns)),
},
});
setWeightCalcError('');
message.success('已根据纱线配比计算克重');
};
const renderYarnRatioTable = (side: YarnSide) => {
const rows = getYarnRows(side);
const label = ratioSideLabels[side];
const error = getRatioError(side, rows);
const selectedYarnIds = new Set(rows.map((row) => row.yarnId));
const options = [
{ label: `+ 创建纱线`, value: CREATE_YARN_OPTION },
...yarnOptions
.filter((yarn) => !selectedYarnIds.has(yarn.yarnId))
.map((yarn) => ({
label: `${yarn.yarnName} / ${yarn.color || '未设置颜色'}`,
value: yarn.yarnId,
})),
];
const total = getRatioTotal(rows);
return (
<div style={{ marginBottom: 16 }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 8 }}>
<strong>{label}</strong>
<Select
aria-label={`添加${label}纱线`}
showSearch
value={undefined}
placeholder={`添加${label}纱线`}
style={{ width: 260 }}
filterOption={false}
options={options}
onSearch={loadYarnOptions}
onFocus={() => loadYarnOptions()}
onChange={(value) => handleSelectYarn(side, value)}
/>
</Space>
<Table<YarnRatioRow>
rowKey="key"
size="small"
pagination={false}
dataSource={rows}
locale={{ emptyText: '尚未添加纱线' }}
columns={[
{
title: '纱线名称/纱线颜色',
dataIndex: 'yarnName',
render: (_, row) => (
<Space size={4}>
<span>{row.yarnName}</span>
<span style={{ color: 'var(--ant-color-text-secondary)' }}>/ {row.yarnColor}</span>
</Space>
),
},
{
title: '使用配比(合计 10',
dataIndex: 'ratio',
width: 180,
render: (_, row) => (
<InputNumber
aria-label={`${label}${row.yarnName}使用配比`}
min={1}
max={10}
precision={0}
step={1}
value={row.ratio}
onChange={(value) => updateYarnRatio(side, row.key, value)}
/>
),
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" onClick={() => removeYarnRatioRow(side, row.key)} style={{ padding: 0, height: 'auto' }}></Button>
),
},
]}
footer={() => (
<span style={error ? prominentErrorTextStyle : undefined}>
{total} / 10{rows.length === 0 ? '(未添加)' : ''}
</span>
)}
/>
{error && (
<div role="alert" style={{ marginTop: 6, ...prominentErrorTextStyle }}>
{error}
</div>
)}
</div>
);
};
// ── Panel 1: Product Summary ──
const summaryColumns: ProColumns<ProductSummaryItem>[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
@ -220,7 +502,7 @@ const ProductsPage: React.FC = () => {
}}
toolBarRender={() => [
<Segmented key="unit" size="small" value={unit} options={[{ label: 'cm', value: 'cm' }, { label: 'in', value: 'in' }]} onChange={(value) => setUnit(value as 'cm' | 'in')} />,
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>,
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => handleCreateOpenChange(true)}></Button>,
<Button key="import" icon={<UploadOutlined />} onClick={() => window.location.assign('/inventory/import')}>Excel导入</Button>,
<Button key="export" icon={<DownloadOutlined />} onClick={handleExport}>Excel导出</Button>,
]}
@ -246,16 +528,19 @@ const ProductsPage: React.FC = () => {
<ModalForm
title="新增产品"
open={createOpen}
onOpenChange={setCreateOpen}
form={createForm}
onOpenChange={handleCreateOpenChange}
onFinish={async (values) => {
const selectedYarnIds = values.selectedYarnIds || [];
const ratioErrors = getRatioErrors();
if (ratioErrors.length > 0) {
const errorText = ratioErrors.join('');
setRatioSubmitError(errorText);
message.error(errorText);
return false;
}
const initialBatch = values.initialBatch || {};
const yarnRatio = initialBatch.yarnRatio || (selectedYarnIds.length ? JSON.stringify({
warp: selectedYarnIds.map((yarnId: string) => ({ yarn_id: yarnId, ratio: 1 })),
weft: [],
}) : '');
const yarnRatio = serializeYarnRatio();
const productValues = { ...values };
delete productValues.selectedYarnIds;
const res = await createProduct({
...productValues,
salesPrice: String(values.salesPrice || '0'),
@ -271,21 +556,25 @@ const ProductsPage: React.FC = () => {
<ProFormText name="color" label="颜色" initialValue="本色" />
<ProFormText name="colorNo" label="色号" />
<ProFormText name="salesPrice" label="销售价" />
<ProFormSelect
name="selectedYarnIds"
label="纱线"
request={async ({ keyWords }) => {
const res = await listYarns({ page: 1, pageSize: 50, yarnName: keyWords });
return (res?.data?.list || []).map((y) => ({ label: `${y.yarnName} / ${y.color}`, value: y.yarnId }));
}}
fieldProps={{ showSearch: true, mode: 'multiple' }}
/>
<Button type="dashed" size="small" onClick={() => setYarnOpen(true)} style={{ marginBottom: 12 }}>线</Button>
<ProFormTextArea name={['initialBatch', 'yarnRatio']} label="纱线配比" />
<ProFormText name={['initialBatch', 'warpWeightGM']} label="经线克重(g/m)" />
<ProFormText name={['initialBatch', 'weftWeightGM']} label="纬线克重(g/m)" />
<ProFormTextArea name={['initialBatch', 'productionProcess']} label="生产工艺" initialValue="无" />
<ProFormTextArea name="remark" label="备注" />
<div style={{ marginBottom: 8, fontWeight: 600 }}>线</div>
{ratioSubmitError && (
<Alert type="error" showIcon message={<span style={prominentErrorTextStyle}>{ratioSubmitError}</span>} style={{ marginBottom: 12 }} />
)}
{renderYarnRatioTable('warp')}
{renderYarnRatioTable('weft')}
<div style={{ marginBottom: 8, fontWeight: 600 }}></div>
<Button type="link" onClick={calculateBatchWeight} style={{ padding: 0, height: 'auto', marginBottom: 6 }}>
线
</Button>
{weightCalcError && (
<div role="alert" style={{ marginBottom: 8, ...prominentErrorTextStyle }}>
{weightCalcError}
</div>
)}
<ProFormText name={['initialBatch', 'warpWeightGM']} label="经线克重(g/m)" rules={[{ pattern: /^\d+(?:\.\d+)?$/, message: '请输入数字' }]} />
<ProFormText name={['initialBatch', 'weftWeightGM']} label="纬线克重(g/m)" rules={[{ pattern: /^\d+(?:\.\d+)?$/, message: '请输入数字' }]} />
<ProFormText name={['initialBatch', 'productionProcess']} label="生产工艺" initialValue="无" />
<ProFormText name="remark" label="备注" />
</ModalForm>
{/* Edit product modal */}
@ -347,10 +636,34 @@ const ProductsPage: React.FC = () => {
<ModalForm
title="新增纱线"
open={yarnOpen}
onOpenChange={setYarnOpen}
onOpenChange={(open) => {
setYarnOpen(open);
if (!open) setYarnCreateTarget(null);
}}
onFinish={async (values) => {
const res = await createYarn(values);
if (res?.code === 0) {
const createdYarnId = res.data?.id || '';
const createdYarn: YarnInfo = {
yarnId: createdYarnId,
yarnName: values.yarnName,
color: values.color || '原纱本色',
weightGM: values.weightGM,
supplierId: values.supplierId,
dyeFactory: values.dyeFactory,
remark: values.remark,
};
if (createdYarn.yarnId) {
setYarnOptions((options) => [
createdYarn,
...options.filter((item) => item.yarnId !== createdYarn.yarnId),
]);
if (yarnCreateTarget) {
appendYarnRatioRow(yarnCreateTarget, createdYarn);
}
} else {
loadYarnOptions();
}
message.success('创建成功');
return true;
}
@ -360,7 +673,7 @@ const ProductsPage: React.FC = () => {
>
<ProFormText name="yarnName" label="纱线名称" rules={[{ required: true }]} />
<ProFormText name="color" label="颜色" initialValue="原纱本色" />
<ProFormText name="weightGM" label="纱线克重(g/m)" rules={[{ required: true }]} />
<ProFormText name="weightGM" label="纱线克重(g/m)" rules={[{ required: true }, { pattern: /^\d+(?:\.\d+)?$/, message: '请输入数字' }]} />
<ProFormSelect
name="supplierId"
label="供应商"
@ -427,10 +740,11 @@ const ProductsPage: React.FC = () => {
placeholder="批次"
value={pan.batchId}
style={{ width: 180 }}
allowClear
options={batches.map((batch) => ({ label: batch.batchNo, value: batch.batchId }))}
onChange={(value) => {
const next = [...panInputs];
next[pi] = { ...next[pi], batchId: value };
next[pi] = { ...next[pi], batchId: value || undefined };
setPanInputs(next);
}}
/>

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

@ -111,3 +111,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);

View File

@ -4,6 +4,7 @@ import type {
BoltInfo,
ColorDetailItem,
CurrentUser,
InboundRecordInfo,
InventoryLog,
LoginParams,
LoginResult,
@ -16,6 +17,7 @@ import type {
Product,
ProductBatchInfo,
ProductBatchInput,
ProductionPlanInfo,
ProductSummaryItem,
ProductTree,
PurchaseOrder,
@ -27,6 +29,8 @@ import type {
Relation,
RelationHistory,
Role,
ShareLinkInfo,
ShareLinkPlanView,
StockAdjust,
StockCheck,
StockGroup,
@ -164,8 +168,8 @@ export const listProductBatches = (productId: string) =>
export const createProductBatch = (productId: string, batch: ProductBatchInput) =>
request<ApiResponse<{ id: string }>>(`/api/v1/inventory/products/${productId}/batches`, { method: 'POST', data: { batch } });
export const updateProductBatch = (productId: string, batchId: string, batch: ProductBatchInput & { status?: number }) =>
request<ApiResponse>(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'PUT', data: { batch, status: batch.status ?? 1 } });
export const updateProductBatch = (productId: string, batchId: string, batch: ProductBatchInput, status?: number) =>
request<ApiResponse>(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'PUT', data: status === undefined ? { batch } : { batch, status } });
export const deleteProductBatch = (productId: string, batchId: string) =>
request<ApiResponse>(`/api/v1/inventory/products/${productId}/batches/${batchId}`, { method: 'DELETE' });
@ -370,3 +374,45 @@ export const getPurchaseStatsByProduct = (params: { startDate?: string; endDate?
export const listInventoryLogs = (params: PaginationParams & { productId?: string; changeType?: number; startDate?: string; endDate?: string }) =>
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

@ -441,3 +441,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;
}