Merge branch 'main' into feat/a-better-product-table
This commit is contained in:
commit
48ec6ffc18
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.superpowers
|
||||||
|
__mocks__
|
||||||
|
test
|
||||||
|
*.md
|
||||||
|
.umirc.local.ts
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,6 +2,9 @@
|
|||||||
# Created by https://www.toptal.com/developers/gitignore/api/node,macos,visualstudiocode
|
# Created by https://www.toptal.com/developers/gitignore/api/node,macos,visualstudiocode
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,visualstudiocode
|
# Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,visualstudiocode
|
||||||
|
|
||||||
|
# Allow src/pages Logs (overrides 'logs' ignore rule below)
|
||||||
|
!src/pages/**/Logs/
|
||||||
|
|
||||||
### macOS ###
|
### macOS ###
|
||||||
# General
|
# General
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
46
.gitlab-ci.yml
Normal file
46
.gitlab-ci.yml
Normal 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
|
||||||
17
.umirc.ts
17
.umirc.ts
@ -83,6 +83,23 @@ export default defineConfig({
|
|||||||
{ name: '采购报表', path: '/purchase/stats', component: './Purchase/Stats' },
|
{ name: '采购报表', path: '/purchase/stats', component: './Purchase/Stats' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '生产管理',
|
||||||
|
path: '/production',
|
||||||
|
icon: 'ExperimentOutlined',
|
||||||
|
routes: [
|
||||||
|
{ name: '生产计划', path: '/production/plans', component: './Production/Plans/index' },
|
||||||
|
{ name: '新建计划', path: '/production/plans/new', component: './Production/Plans/new', hideInMenu: true },
|
||||||
|
{ name: '计划详情', path: '/production/plans/:planId', component: './Production/Plans/detail', hideInMenu: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '分享链接',
|
||||||
|
path: '/share/production/:shortCode',
|
||||||
|
component: './Production/Share/index',
|
||||||
|
hideInMenu: true,
|
||||||
|
layout: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: '系统管理',
|
name: '系统管理',
|
||||||
path: '/system',
|
path: '/system',
|
||||||
|
|||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
ARG BASE_REGISTRY=harbor-in-k3s.cheverjohn.me/library
|
||||||
|
FROM ${BASE_REGISTRY}/node:20-alpine AS builder
|
||||||
|
|
||||||
|
RUN sed -i "s|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g" /etc/apk/repositories
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile --registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM ${BASE_REGISTRY}/nginx:1.27-alpine
|
||||||
|
RUN sed -i "s|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g" /etc/apk/repositories
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
31
nginx.conf
Normal file
31
nginx.conf
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/pages/Inventory/Logs/index.tsx
Normal file
44
src/pages/Inventory/Logs/index.tsx
Normal 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;
|
||||||
28
src/pages/Production/Plans/detail.test.tsx
Normal file
28
src/pages/Production/Plans/detail.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
331
src/pages/Production/Plans/detail.tsx
Normal file
331
src/pages/Production/Plans/detail.tsx
Normal 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;
|
||||||
15
src/pages/Production/Plans/index.test.tsx
Normal file
15
src/pages/Production/Plans/index.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
85
src/pages/Production/Plans/index.tsx
Normal file
85
src/pages/Production/Plans/index.tsx
Normal 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;
|
||||||
15
src/pages/Production/Plans/new.test.tsx
Normal file
15
src/pages/Production/Plans/new.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
208
src/pages/Production/Plans/new.tsx
Normal file
208
src/pages/Production/Plans/new.tsx
Normal 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;
|
||||||
28
src/pages/Production/Share/index.test.tsx
Normal file
28
src/pages/Production/Share/index.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
174
src/pages/Production/Share/index.tsx
Normal file
174
src/pages/Production/Share/index.tsx
Normal 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;
|
||||||
@ -111,3 +111,50 @@ export const getPurchaseStatsSummary = vi.fn(ok);
|
|||||||
export const getPurchaseStatsBySupplier = vi.fn(ok);
|
export const getPurchaseStatsBySupplier = vi.fn(ok);
|
||||||
export const getPurchaseStatsByProduct = vi.fn(ok);
|
export const getPurchaseStatsByProduct = vi.fn(ok);
|
||||||
export const listInventoryLogs = vi.fn(ok);
|
export const listInventoryLogs = vi.fn(ok);
|
||||||
|
|
||||||
|
// ── Production ───────────────────────────────────
|
||||||
|
|
||||||
|
export const createProductionPlan = vi.fn(ok);
|
||||||
|
export const listProductionPlan = vi.fn(ok);
|
||||||
|
export const getProductionPlan = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
code: 0, msg: 'ok', success: true,
|
||||||
|
data: {
|
||||||
|
planId: 'plan-1', planCode: 'PP-001', productName: '测试产品', color: '红色',
|
||||||
|
fabricCode: 'FC-01', colorCode: 'CC-01', targetQuantity: '1000', completedQuantity: '0',
|
||||||
|
yarnUsagePerMeter: '0.5', productionPrice: '10', supplierId: 's1', status: 0,
|
||||||
|
remark: '', startTime: '2024-01-01', creator: 'admin', operator: 'admin',
|
||||||
|
createdAt: '2024-01-01', updatedAt: '2024-01-01', tenantId: 't1',
|
||||||
|
yarnRatios: [], processSteps: [], inboundRecords: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
export const confirmPlan = vi.fn(ok);
|
||||||
|
export const createShareLink = vi.fn(ok);
|
||||||
|
export const completeStep = vi.fn(ok);
|
||||||
|
export const createInbound = vi.fn(ok);
|
||||||
|
export const listInbound = vi.fn(ok);
|
||||||
|
export const getShareLinkPlanView = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
code: 0, msg: 'ok', success: true,
|
||||||
|
data: {
|
||||||
|
plan: {
|
||||||
|
planId: 'plan-1', planCode: 'PP-001', productName: '测试产品', color: '红色',
|
||||||
|
fabricCode: 'FC-01', colorCode: 'CC-01', targetQuantity: '1000', completedQuantity: '0',
|
||||||
|
yarnUsagePerMeter: '0.5', productionPrice: '10', supplierId: 's1', status: 1,
|
||||||
|
remark: '', startTime: '2024-01-01', creator: 'admin', operator: 'admin',
|
||||||
|
createdAt: '2024-01-01', updatedAt: '2024-01-01', tenantId: 't1',
|
||||||
|
yarnRatios: [], processSteps: [], inboundRecords: [],
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
linkId: 'link-1', tenantId: 't1', planId: 'plan-1', shortCode: 'abc123',
|
||||||
|
token: 'tok', factoryType: 'weaving', hideSensitive: 0, status: 0,
|
||||||
|
clickCount: 1, clickedAt: '', respondedAt: '', rejectReason: '',
|
||||||
|
expiresAt: '2024-12-31', createdBy: 'admin', createdAt: '2024-01-01', updatedAt: '2024-01-01',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
export const clickShareLink = vi.fn(ok);
|
||||||
|
export const confirmShareLink = vi.fn(ok);
|
||||||
|
export const rejectShareLink = vi.fn(ok);
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type {
|
|||||||
BoltInfo,
|
BoltInfo,
|
||||||
ColorDetailItem,
|
ColorDetailItem,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
|
InboundRecordInfo,
|
||||||
InventoryLog,
|
InventoryLog,
|
||||||
LoginParams,
|
LoginParams,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
@ -16,6 +17,7 @@ import type {
|
|||||||
Product,
|
Product,
|
||||||
ProductBatchInfo,
|
ProductBatchInfo,
|
||||||
ProductBatchInput,
|
ProductBatchInput,
|
||||||
|
ProductionPlanInfo,
|
||||||
ProductSummaryItem,
|
ProductSummaryItem,
|
||||||
ProductTree,
|
ProductTree,
|
||||||
PurchaseOrder,
|
PurchaseOrder,
|
||||||
@ -27,6 +29,8 @@ import type {
|
|||||||
Relation,
|
Relation,
|
||||||
RelationHistory,
|
RelationHistory,
|
||||||
Role,
|
Role,
|
||||||
|
ShareLinkInfo,
|
||||||
|
ShareLinkPlanView,
|
||||||
StockAdjust,
|
StockAdjust,
|
||||||
StockCheck,
|
StockCheck,
|
||||||
StockGroup,
|
StockGroup,
|
||||||
@ -370,3 +374,45 @@ export const getPurchaseStatsByProduct = (params: { startDate?: string; endDate?
|
|||||||
|
|
||||||
export const listInventoryLogs = (params: PaginationParams & { productId?: string; changeType?: number; startDate?: string; endDate?: string }) =>
|
export const listInventoryLogs = (params: PaginationParams & { productId?: string; changeType?: number; startDate?: string; endDate?: string }) =>
|
||||||
request<ApiResponse<PaginatedData<InventoryLog>>>('/api/v1/inventory/logs', { method: 'GET', params });
|
request<ApiResponse<PaginatedData<InventoryLog>>>('/api/v1/inventory/logs', { method: 'GET', params });
|
||||||
|
|
||||||
|
// ── Production Plans ─────────────────────────────
|
||||||
|
|
||||||
|
export const createProductionPlan = (data: any) =>
|
||||||
|
request<ApiResponse<{ id: string }>>('/api/v1/production/plan', { method: 'POST', data });
|
||||||
|
|
||||||
|
export const listProductionPlan = (params: { page: number; pageSize: number; status?: number; productName?: string; planCode?: string }) =>
|
||||||
|
request<ApiResponse<{ total: number; list: ProductionPlanInfo[] }>>('/api/v1/production/plan', { method: 'GET', params });
|
||||||
|
|
||||||
|
export const getProductionPlan = (planId: string) =>
|
||||||
|
request<ApiResponse<ProductionPlanInfo>>(`/api/v1/production/plan/${planId}`, { method: 'GET' });
|
||||||
|
|
||||||
|
export const confirmPlan = (data: { planId: string }) =>
|
||||||
|
request<ApiResponse<null>>('/api/v1/production/plan/confirm', { method: 'POST', data });
|
||||||
|
|
||||||
|
export const createShareLink = (data: { planId: string; expiresHours?: number; hideSensitive?: number }) =>
|
||||||
|
request<ApiResponse<ShareLinkInfo>>('/api/v1/production/share', { method: 'POST', data });
|
||||||
|
|
||||||
|
export const completeStep = (data: { planId: string; stepType: string }) =>
|
||||||
|
request<ApiResponse<null>>('/api/v1/production/process/step', { method: 'POST', data });
|
||||||
|
|
||||||
|
export const createInbound = (data: { planId: string; batchNo: string; quantity: string; rolls: number; pricePerMeter: string; remark?: string }) =>
|
||||||
|
request<ApiResponse<{ id: string }>>('/api/v1/production/inbound', { method: 'POST', data });
|
||||||
|
|
||||||
|
export const listInbound = (params: { planId: string; page: number; pageSize: number }) =>
|
||||||
|
request<ApiResponse<{ total: number; list: InboundRecordInfo[]; totalQuantity: number; totalRolls: number }>>('/api/v1/production/inbound', { method: 'GET', params });
|
||||||
|
|
||||||
|
// ── Public Share Link (no JWT) ───────────────────
|
||||||
|
|
||||||
|
export const getShareLinkPlanView = (shortCode: string) =>
|
||||||
|
request<ApiResponse<ShareLinkPlanView>>(`/api/v1/public/production/share/${shortCode}`, { method: 'GET' });
|
||||||
|
|
||||||
|
export const clickShareLink = (data: { shortCode: string }) =>
|
||||||
|
request<ApiResponse<null>>('/api/v1/public/production/share/click', { method: 'POST', data });
|
||||||
|
|
||||||
|
// ── Share Link Actions (JWT required) ────────────
|
||||||
|
|
||||||
|
export const confirmShareLink = (data: { shortCode: string }) =>
|
||||||
|
request<ApiResponse<null>>('/api/v1/production/share/confirm', { method: 'POST', data });
|
||||||
|
|
||||||
|
export const rejectShareLink = (data: { shortCode: string; rejectReason: string }) =>
|
||||||
|
request<ApiResponse<null>>('/api/v1/production/share/reject', { method: 'POST', data });
|
||||||
|
|||||||
@ -441,3 +441,92 @@ export interface PurchaseStatsByProductItem {
|
|||||||
avgUnitPrice: string;
|
avgUnitPrice: string;
|
||||||
totalAmount: string;
|
totalAmount: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Production ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProductionPlanInfo {
|
||||||
|
planId: string;
|
||||||
|
tenantId: string;
|
||||||
|
planCode: string;
|
||||||
|
productId: string;
|
||||||
|
productName: string;
|
||||||
|
color: string;
|
||||||
|
fabricCode: string;
|
||||||
|
colorCode: string;
|
||||||
|
targetQuantity: string;
|
||||||
|
completedQuantity: string;
|
||||||
|
yarnUsagePerMeter: string;
|
||||||
|
productionPrice: string;
|
||||||
|
supplierId: string;
|
||||||
|
status: number;
|
||||||
|
remark: string;
|
||||||
|
startTime: string;
|
||||||
|
creator: string;
|
||||||
|
operator: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
yarnRatios: YarnRatioInfo[];
|
||||||
|
processSteps: ProcessStepInfo[];
|
||||||
|
inboundRecords: InboundRecordInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YarnRatioInfo {
|
||||||
|
ratioId: string;
|
||||||
|
planId: string;
|
||||||
|
yarnName: string;
|
||||||
|
ratio: string;
|
||||||
|
amountPerMeter: string;
|
||||||
|
totalAmount: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessStepInfo {
|
||||||
|
stepId: string;
|
||||||
|
planId: string;
|
||||||
|
stepType: string;
|
||||||
|
stepOrder: number;
|
||||||
|
status: number;
|
||||||
|
operatorId: string;
|
||||||
|
completedAt: string;
|
||||||
|
remark: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InboundRecordInfo {
|
||||||
|
recordId: string;
|
||||||
|
tenantId: string;
|
||||||
|
planId: string;
|
||||||
|
productId: string;
|
||||||
|
batchNo: string;
|
||||||
|
quantity: string;
|
||||||
|
rolls: number;
|
||||||
|
pricePerMeter: string;
|
||||||
|
operatorId: string;
|
||||||
|
remark: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareLinkInfo {
|
||||||
|
linkId: string;
|
||||||
|
tenantId: string;
|
||||||
|
planId: string;
|
||||||
|
shortCode: string;
|
||||||
|
token: string;
|
||||||
|
factoryType: string;
|
||||||
|
hideSensitive: number;
|
||||||
|
status: number;
|
||||||
|
clickCount: number;
|
||||||
|
clickedAt: string;
|
||||||
|
respondedAt: string;
|
||||||
|
rejectReason: string;
|
||||||
|
expiresAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareLinkPlanView {
|
||||||
|
plan: ProductionPlanInfo;
|
||||||
|
link: ShareLinkInfo;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user