feat: initial commit for muyu-portal

Umi-based frontend portal with TypeScript config.

Made-with: Cursor
This commit is contained in:
Chever John 2026-02-28 15:29:12 +08:00
commit 0437aa04ed
No known key found for this signature in database
GPG Key ID: ADC4815BFE960182
20 changed files with 15323 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
node_modules/
dist/
.umi/
.umi-production/
*.local
.env
.env.local
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*.swo
*~

106
.umirc.ts Normal file
View File

@ -0,0 +1,106 @@
import { defineConfig } from '@umijs/max';
export default defineConfig({
antd: {},
access: {},
model: {},
initialState: {},
request: {},
layout: {
title: 'muyuqingfeng仓储管理系统',
locale: false,
},
proxy: {
'/api': {
target: 'http://localhost:8888',
changeOrigin: true,
},
},
routes: [
{
path: '/login',
layout: false,
component: './Login',
},
{
path: '/',
redirect: '/dashboard',
},
{
name: '首页',
path: '/dashboard',
component: './Dashboard',
icon: 'DashboardOutlined',
},
{
name: '库存管理',
path: '/inventory',
icon: 'DatabaseOutlined',
routes: [
{
name: '产品档案',
path: '/inventory/products',
component: './Inventory/Products',
},
{
name: '库存查询',
path: '/inventory/stocks',
component: './Inventory/Stocks',
},
{
name: '库存统计',
path: '/inventory/stats',
component: './Inventory/Stats',
},
{
name: '库存盘点',
path: '/inventory/checks',
component: './Inventory/Checks',
},
{
name: '库存调整',
path: '/inventory/adjusts',
component: './Inventory/Adjusts',
},
{
name: 'Excel导入',
path: '/inventory/import',
component: './Inventory/Import',
},
],
},
{
name: '系统管理',
path: '/system',
icon: 'SettingOutlined',
routes: [
{
name: '用户管理',
path: '/system/users',
component: './System/Users',
},
{
name: '角色管理',
path: '/system/roles',
component: './System/Roles',
},
{
name: '菜单管理',
path: '/system/menus',
component: './System/Menus',
},
{
name: '操作日志',
path: '/system/logs',
component: './System/Logs',
},
{
name: '系统配置',
path: '/system/configs',
component: './System/Configs',
},
],
},
],
npmClient: 'pnpm',
});

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "muyu-portal",
"private": true,
"version": "1.0.0",
"description": "Muyu WMS Frontend Portal",
"scripts": {
"dev": "max dev",
"build": "max build",
"postinstall": "max setup",
"lint": "max lint",
"format": "prettier --write ."
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@ant-design/pro-components": "^2.8.7",
"@umijs/max": "^4.4.8",
"antd": "^5.24.2",
"axios": "^1.8.4"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"prettier": "^3.5.3",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
}

14039
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

115
src/app.ts Normal file
View File

@ -0,0 +1,115 @@
import { history, RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
import { message } from 'antd';
const loginPath = '/login';
let redirectingToLogin = false;
function clearSessionAndRedirect(tip?: string) {
localStorage.removeItem('token');
if (tip && !redirectingToLogin) {
message.warning(tip);
}
if (history.location.pathname !== loginPath && !redirectingToLogin) {
redirectingToLogin = true;
history.push(loginPath);
setTimeout(() => {
redirectingToLogin = false;
}, 300);
}
}
export async function getInitialState(): Promise<{
currentUser?: any;
menuPaths?: string[];
}> {
const token = localStorage.getItem('token');
if (!token) {
if (history.location.pathname !== loginPath) {
history.push(loginPath);
}
return {};
}
try {
const res = await fetch('/api/v1/auth/info', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (data.code === 0 && data.data) {
return {
currentUser: data.data,
menuPaths: data.data.menuPaths || [],
};
}
} catch {}
clearSessionAndRedirect();
return {};
}
export const layout: RunTimeLayoutConfig = (initialState) => {
return {
logo: '/logo.svg',
menu: { locale: false },
logout: () => {
localStorage.removeItem('token');
history.push(loginPath);
},
menuDataRender: (menuData: any[]) => {
const paths = initialState?.initialState?.menuPaths;
if (!paths || paths.length === 0) return menuData;
return filterMenuByPaths(menuData, paths);
},
};
};
function filterMenuByPaths(menus: any[], allowedPaths: string[]): any[] {
const pathSet = new Set(allowedPaths);
return menus
.map((menu) => {
if (menu.children && menu.children.length > 0) {
const filteredChildren = filterMenuByPaths(menu.children, allowedPaths);
if (filteredChildren.length > 0) {
return { ...menu, children: filteredChildren };
}
return pathSet.has(menu.path) ? { ...menu, children: [] } : null;
}
return pathSet.has(menu.path) ? menu : null;
})
.filter(Boolean);
}
export const request: RequestConfig = {
baseURL: '',
timeout: 10000,
requestInterceptors: [
(config: any) => {
const token = localStorage.getItem('token');
if (token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`,
};
}
return config;
},
],
responseInterceptors: [
(response: any) => {
const { data } = response;
if (data?.code === 1003 || data?.code === 1004) {
clearSessionAndRedirect('登录状态已失效,请重新登录');
}
return response;
},
],
errorConfig: {
errorHandler: (error: any) => {
const status = error?.response?.status;
if (status === 401 || status === 403) {
clearSessionAndRedirect('登录状态已失效,请重新登录');
}
throw error;
},
},
};

View File

@ -0,0 +1,72 @@
import { PageContainer, StatisticCard } from '@ant-design/pro-components';
import { Col, Row } from 'antd';
import { useEffect, useState } from 'react';
import { getStockSummary } from '@/services/api';
const { Statistic } = StatisticCard;
const DashboardPage: React.FC = () => {
const [summary, setSummary] = useState<any>({});
useEffect(() => {
getStockSummary().then((res) => {
if (res?.code === 0) {
setSummary(res.data);
}
});
}, []);
return (
<PageContainer>
<Row gutter={[16, 16]}>
<Col span={4}>
<StatisticCard
statistic={{
title: '产品总数',
value: summary.productCount || 0,
suffix: '种',
}}
/>
</Col>
<Col span={5}>
<StatisticCard
statistic={{
title: '总匹数',
value: summary.totalPieces || 0,
suffix: '匹',
}}
/>
</Col>
<Col span={5}>
<StatisticCard
statistic={{
title: '总盘数',
value: summary.totalRolls || 0,
suffix: '盘',
}}
/>
</Col>
<Col span={5}>
<StatisticCard
statistic={{
title: '库存总值(成本)',
value: summary.totalCostValue || '0.00',
prefix: '¥',
}}
/>
</Col>
<Col span={5}>
<StatisticCard
statistic={{
title: '库存总值(销售)',
value: summary.totalSalesValue || '0.00',
prefix: '¥',
}}
/>
</Col>
</Row>
</PageContainer>
);
};
export default DashboardPage;

View File

@ -0,0 +1,51 @@
import { PlusOutlined } from '@ant-design/icons';
import { ProColumns, ProTable } from '@ant-design/pro-components';
import { Button, Tag } from 'antd';
import { listStockAdjusts } from '@/services/api';
const statusMap: Record<number, { text: string; color: string }> = {
0: { text: '待审核', color: 'warning' },
1: { text: '已审核', color: 'success' },
2: { text: '已驳回', color: 'error' },
};
const columns: ProColumns[] = [
{ title: '调整单号', dataIndex: 'adjustNo' },
{ title: '调整日期', dataIndex: 'adjustDate', valueType: 'date', search: false },
{ title: '调整原因', dataIndex: 'adjustReason', search: false },
{ title: '操作人', dataIndex: 'operator', search: false },
{
title: '状态', dataIndex: 'status', search: false,
render: (_, record) => {
const s = statusMap[record.status] || statusMap[0];
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{ title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
{ title: '操作', valueType: 'option', render: () => [<a key="view"></a>] },
];
const AdjustsPage: React.FC = () => (
<ProTable
headerTitle="库存调整"
rowKey="adjustId"
columns={columns}
search={false}
request={async (params) => {
const res = await listStockAdjusts({
page: params.current,
pageSize: params.pageSize,
});
return {
data: res?.data?.list || [],
total: res?.data?.total || 0,
success: res?.code === 0,
};
}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />}></Button>,
]}
/>
);
export default AdjustsPage;

View File

@ -0,0 +1,50 @@
import { PlusOutlined } from '@ant-design/icons';
import { ProColumns, ProTable } from '@ant-design/pro-components';
import { Button, Tag } from 'antd';
import { listStockChecks } from '@/services/api';
const statusMap: Record<number, { text: string; color: string }> = {
0: { text: '草稿', color: 'default' },
1: { text: '进行中', color: 'processing' },
2: { text: '已完成', color: 'success' },
};
const columns: ProColumns[] = [
{ title: '盘点单号', dataIndex: 'checkNo' },
{ title: '盘点日期', dataIndex: 'checkDate', valueType: 'date', search: false },
{ title: '盘点人', dataIndex: 'checker', search: false },
{
title: '状态', dataIndex: 'status', search: false,
render: (_, record) => {
const s = statusMap[record.status] || statusMap[0];
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{ title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
{ title: '操作', valueType: 'option', render: () => [<a key="view"></a>] },
];
const ChecksPage: React.FC = () => (
<ProTable
headerTitle="库存盘点"
rowKey="checkId"
columns={columns}
search={false}
request={async (params) => {
const res = await listStockChecks({
page: params.current,
pageSize: params.pageSize,
});
return {
data: res?.data?.list || [],
total: res?.data?.total || 0,
success: res?.code === 0,
};
}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />}></Button>,
]}
/>
);
export default ChecksPage;

View File

@ -0,0 +1,50 @@
import { PageContainer } from '@ant-design/pro-components';
import { InboxOutlined, DownloadOutlined } from '@ant-design/icons';
import { Upload, Card, message, Button, Alert } from 'antd';
const { Dragger } = Upload;
const ImportPage: React.FC = () => {
const token = localStorage.getItem('token');
return (
<PageContainer>
<Card title="Excel 批量导入">
<Alert
message="导入说明"
description="请按照模板格式准备 Excel 文件,必填字段:产品名称、库存数量。支持 .xlsx 和 .xls 格式。"
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
<Button icon={<DownloadOutlined />} style={{ marginBottom: 16 }}>
</Button>
<Dragger
name="file"
action="/api/v1/inventory/products/import"
accept=".xlsx,.xls,.csv"
headers={{ Authorization: `Bearer ${token}` }}
onChange={(info) => {
if (info.file.status === 'done') {
const res = info.file.response;
if (res?.code === 0) {
message.success(`导入完成:成功 ${res.data.successCount} 条,失败 ${res.data.failCount}`);
} else {
message.error(res?.msg || '导入失败');
}
} else if (info.file.status === 'error') {
message.error('文件上传失败');
}
}}
>
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"> .xlsx.xls.csv </p>
</Dragger>
</Card>
</PageContainer>
);
};
export default ImportPage;

View File

@ -0,0 +1,94 @@
import { PlusOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormDigit, ProFormTextArea } from '@ant-design/pro-components';
import { Button, message, Popconfirm } from 'antd';
import { useRef, useState } from 'react';
import { listProducts, createProduct, updateProduct, deleteProduct } from '@/services/api';
const ProductsPage: React.FC = () => {
const actionRef = useRef<ActionType>();
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [currentRow, setCurrentRow] = useState<any>(null);
const columns: ProColumns[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
{ title: '规格型号', dataIndex: 'spec', ellipsis: true },
{ title: '颜色', dataIndex: 'color' },
{ title: '匹数', dataIndex: 'unitPieces', search: false },
{ title: '盘数', dataIndex: 'unitRolls', search: false },
{ title: '库存数量', dataIndex: 'stockQuantity', search: false },
{ title: '存放位置', dataIndex: 'location' },
{ title: '成本价', dataIndex: 'costPrice', search: false, valueType: 'money' },
{ title: '销售价', dataIndex: 'salesPrice', search: false, valueType: 'money' },
{ title: '更新时间', dataIndex: 'updatedAt', valueType: 'dateTime', search: false },
{
title: '操作', valueType: 'option',
render: (_, record) => [
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); }}></a>,
<Popconfirm key="delete" title="确认删除?" onConfirm={async () => {
const res = await deleteProduct(record.productId);
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
}}>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
],
},
];
const productFormFields = (
<>
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
<ProFormText name="spec" label="规格型号" />
<ProFormText name="color" label="颜色" />
<ProFormDigit name="unitPieces" label="匹数" min={0} />
<ProFormDigit name="unitRolls" label="盘数" min={0} />
<ProFormText name="stockQuantity" label="库存数量" rules={[{ required: true }]} />
<ProFormText name="location" label="存放位置" />
<ProFormText name="costPrice" label="成本价" />
<ProFormText name="salesPrice" label="销售价" />
<ProFormTextArea name="remark" label="备注" />
</>
);
return (
<>
<ProTable
headerTitle="产品档案"
actionRef={actionRef}
rowKey="productId"
columns={columns}
request={async (params) => {
const res = await listProducts({
page: params.current, pageSize: params.pageSize,
productName: params.productName, spec: params.spec,
color: params.color, location: params.location,
});
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>,
<Button key="import" icon={<UploadOutlined />} onClick={() => window.location.href = '/inventory/import'}>Excel导入</Button>,
<Button key="export" icon={<DownloadOutlined />}>Excel导出</Button>,
]}
/>
<ModalForm title="新增产品" open={createOpen} onOpenChange={setCreateOpen}
onFinish={async (values) => {
const res = await createProduct({ ...values, stockQuantity: String(values.stockQuantity || '0'), costPrice: String(values.costPrice || '0'), salesPrice: String(values.salesPrice || '0') });
if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
message.error(res?.msg || '创建失败'); return false;
}}>
{productFormFields}
</ModalForm>
<ModalForm title="编辑产品" open={editOpen} onOpenChange={setEditOpen} initialValues={currentRow}
onFinish={async (values) => {
const res = await updateProduct(currentRow.productId, { ...values, stockQuantity: String(values.stockQuantity || '0'), costPrice: String(values.costPrice || '0'), salesPrice: String(values.salesPrice || '0') });
if (res?.code === 0) { message.success('更新成功'); actionRef.current?.reload(); return true; }
message.error(res?.msg || '更新失败'); return false;
}}>
{productFormFields}
</ModalForm>
</>
);
};
export default ProductsPage;

View File

@ -0,0 +1,60 @@
import { PageContainer, StatisticCard } from '@ant-design/pro-components';
import { Col, Row, Table } from 'antd';
import { useEffect, useState } from 'react';
import { getStockSummary, getStockByColor, getStockByLocation } from '@/services/api';
const StatsPage: React.FC = () => {
const [summary, setSummary] = useState<any>({});
const [colorData, setColorData] = useState<any[]>([]);
const [locationData, setLocationData] = useState<any[]>([]);
useEffect(() => {
getStockSummary().then((res) => res?.code === 0 && setSummary(res.data));
getStockByColor().then((res) => res?.code === 0 && setColorData(res.data?.list || []));
getStockByLocation().then((res) => res?.code === 0 && setLocationData(res.data?.list || []));
}, []);
return (
<PageContainer>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col span={5}><StatisticCard statistic={{ title: '产品总数', value: summary.productCount || 0 }} /></Col>
<Col span={5}><StatisticCard statistic={{ title: '总匹数', value: summary.totalPieces || 0 }} /></Col>
<Col span={5}><StatisticCard statistic={{ title: '总盘数', value: summary.totalRolls || 0 }} /></Col>
<Col span={5}><StatisticCard statistic={{ title: '库存总值(成本)', value: summary.totalCostValue || '0', prefix: '¥' }} /></Col>
<Col span={4}><StatisticCard statistic={{ title: '库存总值(销售)', value: summary.totalSalesValue || '0', prefix: '¥' }} /></Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Table
title={() => '按颜色分类统计'}
dataSource={colorData}
rowKey="name"
columns={[
{ title: '颜色', dataIndex: 'name' },
{ title: '产品数', dataIndex: 'count' },
{ title: '库存量', dataIndex: 'quantity' },
]}
pagination={false}
size="small"
/>
</Col>
<Col span={12}>
<Table
title={() => '按位置分布统计'}
dataSource={locationData}
rowKey="name"
columns={[
{ title: '存放位置', dataIndex: 'name' },
{ title: '产品数', dataIndex: 'count' },
{ title: '库存量', dataIndex: 'quantity' },
]}
pagination={false}
size="small"
/>
</Col>
</Row>
</PageContainer>
);
};
export default StatsPage;

View File

@ -0,0 +1,38 @@
import { ProColumns, ProTable } from '@ant-design/pro-components';
import { listProducts } from '@/services/api';
const columns: ProColumns[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
{ title: '规格型号', dataIndex: 'spec' },
{ title: '颜色', dataIndex: 'color' },
{ title: '匹数', dataIndex: 'unitPieces', search: false },
{ title: '盘数', dataIndex: 'unitRolls', search: false },
{ title: '库存数量', dataIndex: 'stockQuantity', search: false, sorter: true },
{ title: '存放位置', dataIndex: 'location' },
{ title: '成本价', dataIndex: 'costPrice', search: false, valueType: 'money' },
{ title: '销售价', dataIndex: 'salesPrice', search: false, valueType: 'money' },
];
const StocksPage: React.FC = () => (
<ProTable
headerTitle="库存查询"
rowKey="productId"
columns={columns}
request={async (params) => {
const res = await listProducts({
page: params.current,
pageSize: params.pageSize,
productName: params.productName,
color: params.color,
location: params.location,
});
return {
data: res?.data?.list || [],
total: res?.data?.total || 0,
success: res?.code === 0,
};
}}
/>
);
export default StocksPage;

64
src/pages/Login/index.tsx Normal file
View File

@ -0,0 +1,64 @@
import { LoginForm, ProFormText } from '@ant-design/pro-components';
import { history, useModel } from '@umijs/max';
import { message } from 'antd';
import { getUserInfo, login } from '@/services/api';
const LoginPage: React.FC = () => {
const { setInitialState } = useModel('@@initialState');
const handleSubmit = async (values: { username: string; password: string }) => {
try {
const res = await login(values);
if (res.code === 0) {
localStorage.setItem('token', res.data.token);
// Important: load authorized menu paths immediately after login.
// Otherwise, first render may temporarily show full menus until refresh.
const infoRes = await getUserInfo();
if (infoRes?.code === 0 && infoRes.data) {
await setInitialState({
currentUser: infoRes.data,
menuPaths: infoRes.data.menuPaths || [],
});
} else {
await setInitialState({
currentUser: res.data.userInfo,
menuPaths: [],
});
}
message.success('登录成功');
history.push('/dashboard');
} else {
message.error(res.msg || '登录失败');
}
} catch (error) {
message.error('网络异常,请稍后重试');
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#f0f2f5' }}>
<LoginForm
title="muyuqingfeng仓储管理系统"
subTitle="Warehouse Management System"
onFinish={handleSubmit}
>
<ProFormText
name="username"
fieldProps={{ size: 'large' }}
placeholder="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
/>
<ProFormText.Password
name="password"
fieldProps={{ size: 'large' }}
placeholder="密码"
rules={[{ required: true, message: '请输入密码' }]}
/>
</LoginForm>
</div>
);
};
export default LoginPage;

View File

@ -0,0 +1,63 @@
import { PageContainer } from '@ant-design/pro-components';
import { Table, Input, Button, message, Card } from 'antd';
import { useEffect, useState } from 'react';
import { listConfigs, updateConfig } from '@/services/api';
const ConfigsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [editingKey, setEditingKey] = useState('');
const [editValue, setEditValue] = useState('');
const fetchData = async () => {
setLoading(true);
const res = await listConfigs();
if (res?.code === 0) setData(res.data?.list || []);
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const handleSave = async (key: string) => {
const res = await updateConfig(key, editValue);
if (res?.code === 0) {
message.success('更新成功');
setEditingKey('');
fetchData();
}
};
const columns = [
{ title: '配置名称', dataIndex: 'configName', key: 'configName' },
{ title: '配置键', dataIndex: 'configKey', key: 'configKey' },
{
title: '配置值', dataIndex: 'configValue', key: 'configValue',
render: (v: string, record: any) =>
editingKey === record.configKey
? <Input value={editValue} onChange={(e) => setEditValue(e.target.value)} style={{ width: 300 }} />
: v,
},
{ title: '分组', dataIndex: 'configGroup', key: 'configGroup' },
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
{
title: '操作', key: 'action',
render: (_: any, record: any) =>
editingKey === record.configKey
? <>
<a onClick={() => handleSave(record.configKey)} style={{ marginRight: 8 }}></a>
<a onClick={() => setEditingKey('')}></a>
</>
: <a onClick={() => { setEditingKey(record.configKey); setEditValue(record.configValue); }}></a>,
},
];
return (
<PageContainer>
<Card>
<Table columns={columns} dataSource={data} rowKey="configKey" loading={loading} pagination={false} />
</Card>
</PageContainer>
);
};
export default ConfigsPage;

View File

@ -0,0 +1,36 @@
import { ProColumns, ProTable } from '@ant-design/pro-components';
import { Tag } from 'antd';
import { listLogs } from '@/services/api';
const columns: ProColumns[] = [
{ title: '操作人', dataIndex: 'username' },
{ title: '模块', dataIndex: 'module' },
{ title: '操作', dataIndex: 'operation' },
{ title: '方法', dataIndex: 'method', search: false, render: (v) => <Tag>{v}</Tag> },
{ title: '路径', dataIndex: 'path', search: false, ellipsis: true },
{ title: 'IP', dataIndex: 'ip', search: false },
{ title: '耗时(ms)', dataIndex: 'duration', search: false },
{
title: '结果', dataIndex: 'status', search: false,
render: (_, r) => <Tag color={r.status === 1 ? 'success' : 'error'}>{r.status === 1 ? '成功' : '失败'}</Tag>,
},
{ title: '操作时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
];
const LogsPage: React.FC = () => (
<ProTable
headerTitle="操作日志"
rowKey="logId"
columns={columns}
request={async (params) => {
const res = await listLogs({
page: params.current, pageSize: params.pageSize,
username: params.username, module: params.module, operation: params.operation,
});
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
}}
search={{ defaultCollapsed: false }}
/>
);
export default LogsPage;

View File

@ -0,0 +1,67 @@
import { PlusOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { Button, Table, Tag, message, Popconfirm } from 'antd';
import { useEffect, useState } from 'react';
import { listMenus, deleteMenu } from '@/services/api';
const menuTypeMap: Record<number, { text: string; color: string }> = {
1: { text: '目录', color: 'blue' },
2: { text: '菜单', color: 'green' },
3: { text: '按钮', color: 'orange' },
};
const columns = [
{ title: '菜单名称', dataIndex: 'menuName', key: 'menuName' },
{ title: '路由', dataIndex: 'path', key: 'path' },
{ title: '图标', dataIndex: 'icon', key: 'icon' },
{
title: '类型', dataIndex: 'menuType', key: 'menuType',
render: (v: number) => {
const t = menuTypeMap[v] || menuTypeMap[1];
return <Tag color={t.color}>{t.text}</Tag>;
},
},
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder' },
{
title: '操作', key: 'action',
render: (_: any, record: any) => [
<a key="edit" style={{ marginRight: 8 }}></a>,
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
const res = await deleteMenu(record.menuId);
if (res?.code === 0) message.success('删除成功');
}}>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
],
},
];
const MenusPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const fetchData = async () => {
setLoading(true);
const res = await listMenus();
if (res?.code === 0) setData(res.data?.list || []);
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
return (
<PageContainer>
<Button type="primary" icon={<PlusOutlined />} style={{ marginBottom: 16 }}></Button>
<Table
columns={columns}
dataSource={data}
rowKey="menuId"
loading={loading}
childrenColumnName="children"
pagination={false}
/>
</PageContainer>
);
};
export default MenusPage;

View File

@ -0,0 +1,74 @@
import { PlusOutlined } from '@ant-design/icons';
import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormDigit, ProFormTextArea } from '@ant-design/pro-components';
import { Button, message, Popconfirm } from 'antd';
import { useRef, useState } from 'react';
import { listRoles, createRole, updateRole, deleteRole } from '@/services/api';
const RolesPage: React.FC = () => {
const actionRef = useRef<ActionType>();
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [currentRow, setCurrentRow] = useState<any>(null);
const columns: ProColumns[] = [
{ title: '角色名称', dataIndex: 'roleName' },
{ title: '角色标识', dataIndex: 'roleKey', search: false },
{ title: '描述', dataIndex: 'roleDesc', search: false, ellipsis: true },
{ title: '排序', dataIndex: 'sortOrder', search: false },
{ title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
{
title: '操作', valueType: 'option',
render: (_, record) => [
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); }}></a>,
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
const res = await deleteRole(record.roleId);
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
}}>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
],
},
];
return (
<>
<ProTable
headerTitle="角色管理"
actionRef={actionRef}
rowKey="roleId"
columns={columns}
request={async (params) => {
const res = await listRoles({ page: params.current, pageSize: params.pageSize, roleName: params.roleName });
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>,
]}
/>
<ModalForm title="新增角色" open={createOpen} onOpenChange={setCreateOpen}
onFinish={async (values) => {
const res = await createRole(values);
if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
message.error(res?.msg || '创建失败'); return false;
}}>
<ProFormText name="roleName" label="角色名称" rules={[{ required: true }]} />
<ProFormText name="roleKey" label="角色标识" rules={[{ required: true }]} placeholder="如: admin, warehouse, user" />
<ProFormTextArea name="roleDesc" label="描述" />
<ProFormDigit name="sortOrder" label="排序" initialValue={0} />
</ModalForm>
<ModalForm title="编辑角色" open={editOpen} onOpenChange={setEditOpen} initialValues={currentRow}
onFinish={async (values) => {
const res = await updateRole(currentRow.roleId, values);
if (res?.code === 0) { message.success('更新成功'); actionRef.current?.reload(); return true; }
message.error(res?.msg || '更新失败'); return false;
}}>
<ProFormText name="roleName" label="角色名称" />
<ProFormText name="roleKey" label="角色标识" />
<ProFormTextArea name="roleDesc" label="描述" />
<ProFormDigit name="sortOrder" label="排序" />
</ModalForm>
</>
);
};
export default RolesPage;

View File

@ -0,0 +1,120 @@
import { PlusOutlined } from '@ant-design/icons';
import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormSelect } from '@ant-design/pro-components';
import { Button, message, Popconfirm, Switch } from 'antd';
import { useRef, useState } from 'react';
import { listUsers, createUser, updateUser, deleteUser, updateUserStatus, listRoles } from '@/services/api';
const UsersPage: React.FC = () => {
const actionRef = useRef<ActionType>();
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [currentRow, setCurrentRow] = useState<any>(null);
const [roles, setRoles] = useState<any[]>([]);
const fetchRoles = async () => {
const res = await listRoles({ page: 1, pageSize: 100 });
if (res?.code === 0) {
setRoles(res.data?.list?.map((r: any) => ({ label: r.roleName, value: r.roleId })) || []);
}
};
const columns: ProColumns[] = [
{ title: '用户名', dataIndex: 'username' },
{ title: '真实姓名', dataIndex: 'realName' },
{ title: '手机号', dataIndex: 'phone', search: false },
{ title: '邮箱', dataIndex: 'email', search: false },
{ title: '角色', dataIndex: 'roleName', search: false },
{
title: '状态', dataIndex: 'status', search: false,
render: (_, record) => (
<Switch
checked={record.status === 1}
onChange={async (checked) => {
const res = await updateUserStatus(record.userId, checked ? 1 : 0);
if (res?.code === 0) { message.success('状态更新成功'); actionRef.current?.reload(); }
}}
/>
),
},
{ title: '最后登录', dataIndex: 'lastLoginTime', valueType: 'dateTime', search: false },
{
title: '操作', valueType: 'option',
render: (_, record) => [
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); fetchRoles(); }}></a>,
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
const res = await deleteUser(record.userId);
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
}}>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
],
},
];
return (
<>
<ProTable
headerTitle="用户管理"
actionRef={actionRef}
rowKey="userId"
columns={columns}
request={async (params) => {
const res = await listUsers({ page: params.current, pageSize: params.pageSize, username: params.username, realName: params.realName });
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
}}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setCreateOpen(true); fetchRoles(); }}>
</Button>,
]}
/>
<ModalForm
title="新增用户"
open={createOpen}
onOpenChange={setCreateOpen}
onFinish={async (values) => {
const res = await createUser(values);
if (res?.code === 0) {
message.success('创建成功');
actionRef.current?.reload();
return true;
}
message.error(res?.msg || '创建失败');
return false;
}}
>
<ProFormText name="username" label="用户名" rules={[{ required: true }]} />
<ProFormText.Password name="password" label="密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]} />
<ProFormText name="realName" label="真实姓名" rules={[{ required: true }]} />
<ProFormText name="phone" label="手机号" />
<ProFormText name="email" label="邮箱" />
<ProFormSelect name="roleId" label="角色" options={roles} rules={[{ required: true }]} />
</ModalForm>
<ModalForm
title="编辑用户"
open={editOpen}
onOpenChange={setEditOpen}
initialValues={currentRow}
onFinish={async (values) => {
const res = await updateUser(currentRow.userId, values);
if (res?.code === 0) {
message.success('更新成功');
actionRef.current?.reload();
return true;
}
message.error(res?.msg || '更新失败');
return false;
}}
>
<ProFormText name="realName" label="真实姓名" />
<ProFormText name="phone" label="手机号" />
<ProFormText name="email" label="邮箱" />
<ProFormSelect name="roleId" label="角色" options={roles} />
</ModalForm>
</>
);
};
export default UsersPage;

177
src/services/api.ts Normal file
View File

@ -0,0 +1,177 @@
import { request } from '@umijs/max';
// ==================== Auth ====================
export async function login(data: { username: string; password: string }) {
return request('/api/v1/auth/login', { method: 'POST', data });
}
export async function logout() {
return request('/api/v1/auth/logout', { method: 'POST' });
}
export async function getUserInfo() {
return request('/api/v1/auth/info', { method: 'GET' });
}
export async function changePassword(data: { oldPassword: string; newPassword: string }) {
return request('/api/v1/auth/password', { method: 'PUT', data });
}
// ==================== Users ====================
export async function listUsers(params: any) {
return request('/api/v1/system/users', { method: 'GET', params });
}
export async function getUser(id: string) {
return request(`/api/v1/system/users/${id}`, { method: 'GET' });
}
export async function createUser(data: any) {
return request('/api/v1/system/users', { method: 'POST', data });
}
export async function updateUser(id: string, data: any) {
return request(`/api/v1/system/users/${id}`, { method: 'PUT', data });
}
export async function deleteUser(id: string) {
return request(`/api/v1/system/users/${id}`, { method: 'DELETE' });
}
export async function updateUserStatus(id: string, status: number) {
return request(`/api/v1/system/users/${id}/status`, { method: 'PUT', data: { status } });
}
// ==================== Roles ====================
export async function listRoles(params?: any) {
return request('/api/v1/system/roles', { method: 'GET', params });
}
export async function createRole(data: any) {
return request('/api/v1/system/roles', { method: 'POST', data });
}
export async function updateRole(id: string, data: any) {
return request(`/api/v1/system/roles/${id}`, { method: 'PUT', data });
}
export async function deleteRole(id: string) {
return request(`/api/v1/system/roles/${id}`, { method: 'DELETE' });
}
export async function setRolePermissions(id: string, menuIds: string[]) {
return request(`/api/v1/system/roles/${id}/permissions`, { method: 'PUT', data: { menuIds } });
}
// ==================== Menus ====================
export async function listMenus() {
return request('/api/v1/system/menus', { method: 'GET' });
}
export async function createMenu(data: any) {
return request('/api/v1/system/menus', { method: 'POST', data });
}
export async function updateMenu(id: string, data: any) {
return request(`/api/v1/system/menus/${id}`, { method: 'PUT', data });
}
export async function deleteMenu(id: string) {
return request(`/api/v1/system/menus/${id}`, { method: 'DELETE' });
}
// ==================== Logs ====================
export async function listLogs(params: any) {
return request('/api/v1/system/logs', { method: 'GET', params });
}
// ==================== Configs ====================
export async function listConfigs() {
return request('/api/v1/system/configs', { method: 'GET' });
}
export async function updateConfig(key: string, value: string) {
return request(`/api/v1/system/configs/${key}`, { method: 'PUT', data: { configValue: value } });
}
// ==================== Products ====================
export async function listProducts(params: any) {
return request('/api/v1/inventory/products', { method: 'GET', params });
}
export async function getProduct(id: string) {
return request(`/api/v1/inventory/products/${id}`, { method: 'GET' });
}
export async function createProduct(data: any) {
return request('/api/v1/inventory/products', { method: 'POST', data });
}
export async function updateProduct(id: string, data: any) {
return request(`/api/v1/inventory/products/${id}`, { method: 'PUT', data });
}
export async function deleteProduct(id: string) {
return request(`/api/v1/inventory/products/${id}`, { method: 'DELETE' });
}
export async function exportProducts() {
return request('/api/v1/inventory/products/export', { method: 'GET', responseType: 'blob' });
}
// ==================== Stocks ====================
export async function getStockSummary() {
return request('/api/v1/inventory/stocks/summary', { method: 'GET' });
}
export async function getStockByColor() {
return request('/api/v1/inventory/stocks/by-color', { method: 'GET' });
}
export async function getStockByLocation() {
return request('/api/v1/inventory/stocks/by-location', { method: 'GET' });
}
// ==================== Stock Checks ====================
export async function listStockChecks(params: any) {
return request('/api/v1/inventory/checks', { method: 'GET', params });
}
export async function getStockCheck(id: string) {
return request(`/api/v1/inventory/checks/${id}`, { method: 'GET' });
}
export async function createStockCheck(data: any) {
return request('/api/v1/inventory/checks', { method: 'POST', data });
}
export async function confirmStockCheck(id: string) {
return request(`/api/v1/inventory/checks/${id}/confirm`, { method: 'POST' });
}
// ==================== Stock Adjusts ====================
export async function listStockAdjusts(params: any) {
return request('/api/v1/inventory/adjusts', { method: 'GET', params });
}
export async function getStockAdjust(id: string) {
return request(`/api/v1/inventory/adjusts/${id}`, { method: 'GET' });
}
export async function createStockAdjust(data: any) {
return request('/api/v1/inventory/adjusts', { method: 'POST', data });
}
export async function approveStockAdjust(id: string, action: number) {
return request(`/api/v1/inventory/adjusts/${id}/approve`, { method: 'POST', data: { action } });
}

3
tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "./src/.umi/tsconfig.json"
}