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
|
||||
# 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
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: '/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',
|
||||
|
||||
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 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);
|
||||
|
||||
@ -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,
|
||||
@ -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 });
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user