feat: multi-tenant CRM portal with tenant login, supplier/customer views, and graph visualization

- Login page: tenantId mandatory, ruby logo (木羽清風), brand rename to 木羽清風
- Tenant session: localStorage tenantId + X-Tenant-Id header on all requests
- CRM Relations page: "我的供应商" / "我的客户" tabs with Chinese relation type mapping
- CRM Graph page: Terminal CLI aesthetic force-directed supply chain visualization (admin only)
- Menu filtering: empty menuPaths hides all menus (security fix)
- Error handler: 403 no longer triggers logout (security fix)
- API layer: added CRM endpoints (upstream/downstream/relations/history/full graph)
- Route config: added /crm/relations and /crm/graph routes
- app.ts renamed to app.tsx for JSX support in layout config

Made-with: Cursor
This commit is contained in:
Chever John 2026-03-30 02:56:16 +00:00
parent 68fc27a5fd
commit f61e216c43
22 changed files with 2091 additions and 621 deletions

View File

@ -7,9 +7,12 @@ export default defineConfig({
initialState: {}, initialState: {},
request: {}, request: {},
layout: { layout: {
title: 'muyuqingfeng仓储管理系统', title: '木羽清风仓储系统',
locale: false, locale: false,
}, },
headScripts: [
'https://fonts.googleapis.com/css2?family=Calistoga&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap',
],
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8888', target: 'http://localhost:8888',
@ -32,41 +35,34 @@ export default defineConfig({
component: './Dashboard', component: './Dashboard',
icon: 'DashboardOutlined', icon: 'DashboardOutlined',
}, },
{
name: '客户关系',
path: '/crm',
icon: 'NodeIndexOutlined',
routes: [
{
name: '供应商与客户',
path: '/crm/relations',
component: './CRM/Relations',
},
{
name: '关系全景图',
path: '/crm/graph',
component: './CRM/Graph',
},
],
},
{ {
name: '库存管理', name: '库存管理',
path: '/inventory', path: '/inventory',
icon: 'DatabaseOutlined', icon: 'DatabaseOutlined',
routes: [ routes: [
{ { name: '产品档案', path: '/inventory/products', component: './Inventory/Products' },
name: '产品档案', { name: '库存查询', path: '/inventory/stocks', component: './Inventory/Stocks' },
path: '/inventory/products', { name: '库存统计', path: '/inventory/stats', component: './Inventory/Stats' },
component: './Inventory/Products', { name: '库存盘点', path: '/inventory/checks', component: './Inventory/Checks' },
}, { name: '库存调整', path: '/inventory/adjusts', component: './Inventory/Adjusts' },
{ { name: 'Excel导入', path: '/inventory/import', component: './Inventory/Import' },
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',
},
], ],
}, },
{ {
@ -74,31 +70,11 @@ export default defineConfig({
path: '/system', path: '/system',
icon: 'SettingOutlined', icon: 'SettingOutlined',
routes: [ routes: [
{ { name: '用户管理', path: '/system/users', component: './System/Users' },
name: '用户管理', { name: '角色管理', path: '/system/roles', component: './System/Roles' },
path: '/system/users', { name: '菜单管理', path: '/system/menus', component: './System/Menus' },
component: './System/Users', { name: '操作日志', path: '/system/logs', component: './System/Logs' },
}, { name: '系统配置', path: '/system/configs', component: './System/Configs' },
{
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',
},
], ],
}, },
], ],

View File

@ -1,115 +0,0 @@
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;
},
},
};

148
src/app.tsx Normal file
View File

@ -0,0 +1,148 @@
import React from 'react';
import { history, RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
import { message } from 'antd';
import type { CurrentUser } from '@/types/api';
import { antdTheme } from '@/types/theme';
const LOGIN_PATH = '/login';
let redirectingToLogin = false;
function clearSessionAndRedirect(tip?: string) {
localStorage.removeItem('token');
localStorage.removeItem('tenantId');
if (tip && !redirectingToLogin) {
message.warning(tip);
}
if (history.location.pathname !== LOGIN_PATH && !redirectingToLogin) {
redirectingToLogin = true;
history.push(LOGIN_PATH);
setTimeout(() => { redirectingToLogin = false; }, 300);
}
}
interface InitialState {
currentUser?: CurrentUser;
menuPaths?: string[];
}
export async function getInitialState(): Promise<InitialState> {
const token = localStorage.getItem('token');
if (!token) {
if (history.location.pathname !== LOGIN_PATH) {
history.push(LOGIN_PATH);
}
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 {
// network failure — fall through to redirect
}
clearSessionAndRedirect();
return {};
}
export const layout: RunTimeLayoutConfig = (initialState) => ({
logo: false,
title: false,
menuHeaderRender: () => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'default', userSelect: 'none', padding: '16px 0 8px' }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700, letterSpacing: 2, lineHeight: '32px', color: '#1a1a2e' }}>
<ruby style={{ rubyPosition: 'over' }}><rt style={{ fontSize: 10, color: '#94a3b8', fontWeight: 400, letterSpacing: 4 }}></rt></ruby>
<ruby style={{ rubyPosition: 'over' }}><rt style={{ fontSize: 10, color: '#94a3b8', fontWeight: 400, letterSpacing: 2 }}></rt></ruby>
</h2>
</div>
),
menu: { locale: false },
token: {
sider: {
colorMenuBackground: '#FFFFFF',
colorTextMenu: '#64748B',
colorTextMenuSelected: '#0052FF',
colorBgMenuItemSelected: 'rgba(0,82,255,0.06)',
colorTextMenuItemHover: '#0052FF',
},
header: {
colorBgHeader: '#FFFFFF',
heightLayoutHeader: 56,
},
pageContainer: {
paddingBlockPageContainerContent: 24,
paddingInlinePageContainerContent: 24,
},
},
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('tenantId');
history.push(LOGIN_PATH);
},
menuDataRender: (menuData: any[]) => {
const paths = initialState?.initialState?.menuPaths;
if (!paths || paths.length === 0) return [];
return filterMenuByPaths(menuData, paths);
},
});
function filterMenuByPaths(menus: any[], allowedPaths: string[]): any[] {
const pathSet = new Set(allowedPaths);
return menus
.map((menu) => {
if (menu.children?.length) {
const filtered = filterMenuByPaths(menu.children, allowedPaths);
if (filtered.length > 0) return { ...menu, children: filtered };
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');
const tenantId = localStorage.getItem('tenantId');
config.headers = {
...config.headers,
...(token && { Authorization: `Bearer ${token}` }),
...(tenantId && { 'X-Tenant-Id': tenantId }),
};
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) {
clearSessionAndRedirect('登录状态已失效,请重新登录');
}
throw error;
},
},
};
export const antd = {
theme: antdTheme,
};

93
src/global.css Normal file
View File

@ -0,0 +1,93 @@
@import url('https://fonts.googleapis.com/css2?family=Calistoga&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--accent: #0052FF;
--accent-secondary: #4D7CFF;
--accent-foreground: #FFFFFF;
--background: #FAFAFA;
--foreground: #0F172A;
--muted: #F1F5F9;
--muted-foreground: #64748B;
--border: #E2E8F0;
--card: #FFFFFF;
--font-display: 'Calistoga', Georgia, serif;
--font-body: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
body {
font-family: var(--font-body);
background: var(--background);
color: var(--foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Pro Layout sidebar polish */
.ant-pro-sider-menu-logo h1 {
font-family: var(--font-display) !important;
letter-spacing: -0.01em;
}
/* ProTable card wrapper */
.ant-pro-table .ant-pro-card {
border-radius: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
border: 1px solid var(--border);
}
/* Subtle hover lift on stat cards */
.ant-statistic-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 82, 255, 0.1);
transition: all 0.3s ease;
}
/* Better scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* Gradient text utility */
.gradient-text {
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Focus ring consistency */
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
/* Modal overlay */
.ant-modal-mask {
backdrop-filter: blur(4px);
}
/* Pro Layout content area padding */
.ant-pro-page-container-children-content {
padding: 0 !important;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

View File

@ -0,0 +1,446 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { request } from '@umijs/max';
interface GNode {
id: string;
name: string;
x: number;
y: number;
vx: number;
vy: number;
}
interface GEdge {
source: string;
target: string;
relationType: string;
relationshipId: string;
}
const T = {
bg: '#0a0a0a',
grid: '#0d1a0d',
green: '#33ff00',
amber: '#ffb000',
red: '#ff3333',
muted: '#1f521f',
dimGreen: '#145214',
cyan: '#00ffcc',
white: '#c0ffc0',
font: '"JetBrains Mono", "Fira Code", "Courier New", monospace',
};
const relLabel: Record<string, string> = { SUPPLY: 'SUPPLY', OEM: 'OEM', PURCHASE: 'PURCHASE' };
const relColor: Record<string, string> = { SUPPLY: T.green, OEM: T.amber, PURCHASE: T.cyan };
const GraphPage: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const overlayRef = useRef<HTMLCanvasElement>(null);
const [loading, setLoading] = useState(true);
const nodesRef = useRef<GNode[]>([]);
const edgesRef = useRef<GEdge[]>([]);
const animRef = useRef<number>(0);
const dragRef = useRef<{ node: GNode | null; ox: number; oy: number }>({ node: null, ox: 0, oy: 0 });
const hoverRef = useRef<GNode | null>(null);
const [info, setInfo] = useState({ nodes: 0, edges: 0 });
const [hoverInfo, setHoverInfo] = useState<{ x: number; y: number; lines: string[] } | null>(null);
const frameRef = useRef(0);
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
useEffect(() => {
(async () => {
try {
const res = await request('/api/v1/crm/graph/full', { method: 'GET' });
if (res?.code === 0 && res.data) {
const W = 1200, H = 720;
const ns: GNode[] = (res.data.nodes || []).map((nd: any, i: number) => ({
...nd,
x: W / 2 + 280 * Math.cos((2 * Math.PI * i) / (res.data.nodes?.length || 1)),
y: H / 2 + 220 * Math.sin((2 * Math.PI * i) / (res.data.nodes?.length || 1)),
vx: 0, vy: 0,
}));
nodesRef.current = ns;
edgesRef.current = res.data.edges || [];
setInfo({ nodes: ns.length, edges: (res.data.edges || []).length });
}
} finally {
setLoading(false);
}
})();
}, []);
const drawScanlines = useCallback((canvas: HTMLCanvasElement) => {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = 'rgba(0,0,0,0.03)';
for (let y = 0; y < h; y += 4 * dpr) {
ctx.fillRect(0, y, w, 2 * dpr);
}
}, [dpr]);
useEffect(() => {
if (loading) return;
const nodes = nodesRef.current;
const edges = edgesRef.current;
if (nodes.length === 0) return;
const canvas = canvasRef.current;
const overlay = overlayRef.current;
if (!canvas || !overlay) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const CW = 1200, CH = 720;
canvas.width = CW * dpr;
canvas.height = CH * dpr;
canvas.style.width = CW + 'px';
canvas.style.height = CH + 'px';
ctx.scale(dpr, dpr);
overlay.width = CW * dpr;
overlay.height = CH * dpr;
overlay.style.width = CW + 'px';
overlay.style.height = CH + 'px';
drawScanlines(overlay);
const nodeMap = new Map<string, GNode>();
nodes.forEach((n) => nodeMap.set(n.id, n));
const tick = () => {
frameRef.current++;
const alpha = 0.25;
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
let dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const f = 12000 / (dist * dist);
const fx = (dx / dist) * f * alpha, fy = (dy / dist) * f * alpha;
a.vx -= fx; a.vy -= fy;
b.vx += fx; b.vy += fy;
}
}
edges.forEach((e) => {
const a = nodeMap.get(e.source), b = nodeMap.get(e.target);
if (!a || !b) return;
let dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const f = (dist - 200) * 0.04 * alpha;
const fx = (dx / dist) * f, fy = (dy / dist) * f;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
});
nodes.forEach((n) => {
n.vx += (CW / 2 - n.x) * 0.008;
n.vy += (CH / 2 - n.y) * 0.008;
});
nodes.forEach((n) => {
if (dragRef.current.node === n) return;
n.vx *= 0.55; n.vy *= 0.55;
n.x += n.vx; n.y += n.vy;
n.x = Math.max(60, Math.min(CW - 60, n.x));
n.y = Math.max(50, Math.min(CH - 50, n.y));
});
ctx.clearRect(0, 0, CW, CH);
ctx.fillStyle = T.bg;
ctx.fillRect(0, 0, CW, CH);
ctx.strokeStyle = T.grid;
ctx.lineWidth = 0.5;
for (let x = 0; x < CW; x += 24) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, CH); ctx.stroke();
}
for (let y = 0; y < CH; y += 24) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(CW, y); ctx.stroke();
}
ctx.strokeStyle = T.muted;
ctx.lineWidth = 1;
ctx.setLineDash([2, 6]);
for (let x = 0; x < CW; x += 120) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, CH); ctx.stroke();
}
for (let y = 0; y < CH; y += 120) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(CW, y); ctx.stroke();
}
ctx.setLineDash([]);
edges.forEach((e) => {
const a = nodeMap.get(e.source), b = nodeMap.get(e.target);
if (!a || !b) return;
const color = relColor[e.relationType] || T.green;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.shadowColor = color;
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
ctx.restore();
const angle = Math.atan2(b.y - a.y, b.x - a.x);
const arrowDist = 32;
const ax = b.x - arrowDist * Math.cos(angle);
const ay = b.y - arrowDist * Math.sin(angle);
const aLen = 10;
ctx.save();
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 4;
ctx.beginPath();
ctx.moveTo(ax + aLen * Math.cos(angle), ay + aLen * Math.sin(angle));
ctx.lineTo(ax - aLen * 0.6 * Math.cos(angle - 0.5), ay - aLen * 0.6 * Math.sin(angle - 0.5));
ctx.lineTo(ax - aLen * 0.6 * Math.cos(angle + 0.5), ay - aLen * 0.6 * Math.sin(angle + 0.5));
ctx.closePath();
ctx.fill();
ctx.restore();
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
ctx.save();
ctx.font = `10px ${T.font}`;
ctx.fillStyle = color;
ctx.globalAlpha = 0.7;
ctx.textAlign = 'center';
ctx.fillText(relLabel[e.relationType] || e.relationType, mx, my - 8);
ctx.restore();
});
nodes.forEach((n) => {
const x = n.x, y = n.y;
const isDefault = n.id === 't_default_001';
const isSupplier = n.id.includes('supplier');
const isHover = hoverRef.current === n;
const color = isDefault ? T.green : isSupplier ? T.amber : T.cyan;
const half = isDefault ? 30 : 24;
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = isHover ? 20 : 10;
ctx.strokeStyle = color;
ctx.lineWidth = isHover ? 2 : 1;
ctx.strokeRect(x - half, y - half, half * 2, half * 2);
ctx.fillStyle = isHover ? color : T.bg;
ctx.globalAlpha = isHover ? 0.15 : 0.6;
ctx.fillRect(x - half, y - half, half * 2, half * 2);
ctx.globalAlpha = 1;
ctx.fillStyle = color;
ctx.fillRect(x - half, y - half, half * 2, 14);
const tag = isDefault ? '[SYS]' : isSupplier ? '[SUP]' : '[CUS]';
ctx.font = `bold 9px ${T.font}`;
ctx.fillStyle = T.bg;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(tag, x, y - half + 2);
ctx.font = `${isDefault ? 'bold ' : ''}12px ${T.font}`;
ctx.fillStyle = color;
ctx.textBaseline = 'middle';
const label = n.name.length > 5 ? n.name.slice(0, 5) + '..' : n.name;
ctx.fillText(label, x, y + 4);
ctx.restore();
ctx.save();
ctx.font = `10px ${T.font}`;
ctx.fillStyle = T.muted;
ctx.textAlign = 'center';
ctx.shadowColor = color;
ctx.shadowBlur = 3;
ctx.fillText(n.name, x, y + half + 14);
ctx.restore();
});
// HUD
ctx.save();
ctx.strokeStyle = T.muted;
ctx.lineWidth = 1;
ctx.strokeRect(8, 8, 200, 140);
ctx.fillStyle = T.bg;
ctx.globalAlpha = 0.85;
ctx.fillRect(8, 8, 200, 140);
ctx.globalAlpha = 1;
ctx.fillStyle = T.green;
ctx.fillRect(8, 8, 200, 16);
ctx.font = `bold 10px ${T.font}`;
ctx.fillStyle = T.bg;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(' LEGEND ', 12, 10);
const items = [
{ c: T.green, t: '■ [SYS] CURRENT_ORG' },
{ c: T.amber, t: '■ [SUP] SUPPLIER' },
{ c: T.cyan, t: '■ [CUS] CUSTOMER' },
{ c: T.green, t: '─ SUPPLY' },
{ c: T.amber, t: '─ OEM' },
{ c: T.cyan, t: '─ PURCHASE' },
];
items.forEach((item, i) => {
ctx.fillStyle = item.c;
ctx.shadowColor = item.c;
ctx.shadowBlur = 3;
ctx.font = `10px ${T.font}`;
ctx.fillText(item.t, 16, 32 + i * 17);
});
ctx.shadowBlur = 0;
ctx.restore();
// Status bar
ctx.save();
ctx.fillStyle = T.dimGreen;
ctx.fillRect(0, CH - 22, CW, 22);
ctx.font = `11px ${T.font}`;
ctx.fillStyle = T.green;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const blink = frameRef.current % 60 < 30 ? '█' : ' ';
ctx.fillText(` $ graph --nodes=${nodes.length} --edges=${edges.length} --mode=force-directed ${blink}`, 8, CH - 11);
ctx.textAlign = 'right';
ctx.fillStyle = T.muted;
ctx.fillText(`[DRAG TO MOVE] [HOVER FOR INFO] `, CW - 8, CH - 11);
ctx.restore();
animRef.current = requestAnimationFrame(tick);
};
animRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(animRef.current);
}, [loading, dpr, drawScanlines]);
const getCanvasXY = (e: React.MouseEvent): [number, number] => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return [0, 0];
return [e.clientX - rect.left, e.clientY - rect.top];
};
const findNode = (mx: number, my: number): GNode | undefined => {
return nodesRef.current.find((n) => Math.abs(n.x - mx) < 30 && Math.abs(n.y - my) < 30);
};
const handleMouseDown = (e: React.MouseEvent) => {
const [mx, my] = getCanvasXY(e);
const node = findNode(mx, my);
if (node) dragRef.current = { node, ox: mx - node.x, oy: my - node.y };
};
const handleMouseMove = (e: React.MouseEvent) => {
const [mx, my] = getCanvasXY(e);
if (dragRef.current.node) {
dragRef.current.node.x = mx - dragRef.current.ox;
dragRef.current.node.y = my - dragRef.current.oy;
dragRef.current.node.vx = 0;
dragRef.current.node.vy = 0;
return;
}
const node = findNode(mx, my);
hoverRef.current = node || null;
if (node) {
const edges = edgesRef.current;
const sup = edges.filter((e) => e.target === node.id).length;
const cus = edges.filter((e) => e.source === node.id).length;
const rect = canvasRef.current?.getBoundingClientRect();
setHoverInfo({
x: (rect?.left || 0) + mx + 16,
y: (rect?.top || 0) + my - 10,
lines: [
`┌─ ${node.name} ─────────┐`,
`│ ID: ${node.id}`,
`│ SUPPLIERS: ${sup}`,
`│ CUSTOMERS: ${cus}`,
`│ STATUS: [OK]`,
`└────────────────────────┘`,
],
});
} else {
setHoverInfo(null);
}
};
const handleMouseUp = () => { dragRef.current = { node: null, ox: 0, oy: 0 }; };
if (loading) {
return (
<div style={{ background: T.bg, color: T.green, fontFamily: T.font, padding: 40, minHeight: '80vh' }}>
<p style={{ textShadow: `0 0 5px ${T.green}` }}>$ loading supply-chain-graph...</p>
<p style={{ animation: 'blink 1s step-end infinite' }}></p>
</div>
);
}
if (nodesRef.current.length === 0) {
return (
<div style={{ background: T.bg, color: T.amber, fontFamily: T.font, padding: 40, minHeight: '80vh' }}>
<p>$ graph --status</p>
<p style={{ color: T.red }}>[ERR] No relationship data found.</p>
</div>
);
}
return (
<div style={{ background: T.bg, padding: 0, minHeight: '80vh' }}>
{/* Header bar */}
<div style={{
background: T.dimGreen, padding: '6px 16px', fontFamily: T.font, fontSize: 12,
color: T.green, display: 'flex', justifyContent: 'space-between', alignItems: 'center',
borderBottom: `1px solid ${T.muted}`,
}}>
<span style={{ textShadow: `0 0 5px ${T.green}` }}>
+--- SUPPLY CHAIN GRAPH ---+
</span>
<span style={{ color: T.muted }}>
NODES: {info.nodes} | EDGES: {info.edges} | MODE: FORCE-DIRECTED
</span>
</div>
{/* Canvas area */}
<div style={{ position: 'relative', overflow: 'hidden' }}>
<canvas
ref={canvasRef}
style={{ display: 'block', cursor: dragRef.current.node ? 'grabbing' : 'crosshair', width: '100%' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
<canvas
ref={overlayRef}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}
/>
</div>
{/* Tooltip */}
{hoverInfo && (
<pre style={{
position: 'fixed', left: hoverInfo.x, top: hoverInfo.y,
background: T.bg, color: T.green, fontFamily: T.font, fontSize: 11,
padding: '8px 12px', border: `1px solid ${T.green}`, borderRadius: 0,
pointerEvents: 'none', zIndex: 9999, margin: 0, lineHeight: 1.6,
textShadow: `0 0 4px ${T.green}`,
boxShadow: `0 0 12px ${T.green}33`,
}}>
{hoverInfo.lines.join('\n')}
</pre>
)}
</div>
);
};
export default GraphPage;

View File

@ -0,0 +1,195 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Form, Input, message, Select, Space, Table, Tabs, Tag } from 'antd';
import { createRelation, listDownstream, listRelationHistory, listUpstream } from '@/services/api';
const relTypeMap: Record<string, string> = {
SUPPLY: '供货',
PURCHASE: '采购',
OEM: '代工',
};
const RelationsPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [upstream, setUpstream] = useState<any[]>([]);
const [downstream, setDownstream] = useState<any[]>([]);
const [history, setHistory] = useState<any[]>([]);
const [form] = Form.useForm();
const supplierColumns = useMemo(
() => [
{ title: '供应商名称', dataIndex: 'fromTenantName', key: 'fromTenantName', width: 180 },
{ title: '供应商ID', dataIndex: 'fromTenantId', key: 'fromTenantId', width: 160 },
{
title: '关系类型',
dataIndex: 'relationType',
key: 'relationType',
width: 100,
render: (v: string) => relTypeMap[v] || v,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (v: number) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="default"></Tag>),
},
{ title: '生效时间', dataIndex: 'validFrom', key: 'validFrom', width: 180 },
{ title: '关系ID', dataIndex: 'relationshipId', key: 'relationshipId', width: 140 },
],
[],
);
const customerColumns = useMemo(
() => [
{ title: '客户名称', dataIndex: 'toTenantName', key: 'toTenantName', width: 180 },
{ title: '客户ID', dataIndex: 'toTenantId', key: 'toTenantId', width: 160 },
{
title: '关系类型',
dataIndex: 'relationType',
key: 'relationType',
width: 100,
render: (v: string) => relTypeMap[v] || v,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (v: number) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="default"></Tag>),
},
{ title: '生效时间', dataIndex: 'validFrom', key: 'validFrom', width: 180 },
{ title: '关系ID', dataIndex: 'relationshipId', key: 'relationshipId', width: 140 },
],
[],
);
const historyColumns = useMemo(
() => [
{ title: '关系ID', dataIndex: 'relationshipId', key: 'relationshipId' },
{ title: '变更类型', dataIndex: 'changeType', key: 'changeType' },
{ title: '变更前', dataIndex: 'beforeJson', key: 'beforeJson' },
{ title: '变更后', dataIndex: 'afterJson', key: 'afterJson' },
{ title: '操作人', dataIndex: 'changedBy', key: 'changedBy' },
{ title: '时间', dataIndex: 'changedAt', key: 'changedAt' },
],
[],
);
const loadData = async () => {
setLoading(true);
try {
const [upRes, downRes, historyRes] = await Promise.all([
listUpstream(1),
listDownstream(1),
listRelationHistory({ page: 1, pageSize: 50 }),
]);
setUpstream(upRes?.data?.list || []);
setDownstream(downRes?.data?.list || []);
setHistory(historyRes?.data?.list || []);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
return (
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<Card title="新增关系">
<Form
layout="inline"
form={form}
onFinish={async (values) => {
const isSupplier = values.direction === 'supplier';
const payload = {
fromTenantId: isSupplier ? values.targetTenantId : localStorage.getItem('tenantId') || 't_default_001',
toTenantId: isSupplier ? localStorage.getItem('tenantId') || 't_default_001' : values.targetTenantId,
relationType: values.relationType || 'SUPPLY',
};
const res = await createRelation(payload);
if (res.code === 0) {
message.success('关系创建成功');
form.resetFields();
loadData();
return;
}
message.error(res.msg || '关系创建失败');
}}
>
<Form.Item name="direction" initialValue="supplier" rules={[{ required: true }]}>
<Select style={{ width: 130 }} options={[
{ value: 'supplier', label: '添加供应商' },
{ value: 'customer', label: '添加客户' },
]} />
</Form.Item>
<Form.Item name="targetTenantId" rules={[{ required: true, message: '请输入对方租户ID' }]}>
<Input placeholder="对方租户ID" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="relationType" initialValue="SUPPLY">
<Select style={{ width: 120 }} options={[
{ value: 'SUPPLY', label: '供货' },
{ value: 'OEM', label: '代工' },
{ value: 'PURCHASE', label: '采购' },
]} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form.Item>
</Form>
</Card>
<Card>
<Tabs
items={[
{
key: 'suppliers',
label: `我的供应商(${upstream.length}`,
children: (
<Table
rowKey="relationshipId"
loading={loading}
dataSource={upstream}
columns={supplierColumns}
pagination={false}
size="middle"
/>
),
},
{
key: 'customers',
label: `我的客户(${downstream.length}`,
children: (
<Table
rowKey="relationshipId"
loading={loading}
dataSource={downstream}
columns={customerColumns}
pagination={false}
size="middle"
/>
),
},
{
key: 'history',
label: '变更记录',
children: (
<Table
rowKey="changedAt"
loading={loading}
dataSource={history}
columns={historyColumns}
pagination={false}
size="middle"
/>
),
},
]}
/>
</Card>
</Space>
);
};
export default RelationsPage;

View File

@ -1,70 +1,250 @@
import { PageContainer, StatisticCard } from '@ant-design/pro-components'; import React, { useEffect, useState } from 'react';
import { Col, Row } from 'antd'; import { PageContainer } from '@ant-design/pro-components';
import { useEffect, useState } from 'react'; import { Card, Col, Row, Spin } from 'antd';
import {
ShoppingOutlined,
InboxOutlined,
DatabaseOutlined,
DollarOutlined,
RiseOutlined,
} from '@ant-design/icons';
import { getStockSummary } from '@/services/api'; import { getStockSummary } from '@/services/api';
import type { StockSummary } from '@/types/api';
const { Statistic } = StatisticCard; interface StatCardProps {
icon: React.ReactNode;
title: string;
value: string | number;
suffix?: string;
prefix?: string;
gradient?: boolean;
}
const StatCard: React.FC<StatCardProps> = ({ icon, title, value, suffix, prefix, gradient }) => (
<Card
bordered={false}
style={{
borderRadius: 16,
border: gradient ? '2px solid transparent' : '1px solid #E2E8F0',
background: gradient
? 'linear-gradient(135deg, #0052FF, #4D7CFF)'
: '#FFFFFF',
boxShadow: gradient
? '0 8px 24px rgba(0,82,255,0.25)'
: '0 1px 3px rgba(0,0,0,0.06)',
transition: 'all 0.3s ease',
cursor: 'default',
height: '100%',
}}
hoverable
styles={{
body: { padding: '28px 24px' },
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 16,
marginBottom: 16,
}}>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
borderRadius: 12,
background: gradient
? 'rgba(255,255,255,0.2)'
: 'rgba(0,82,255,0.06)',
fontSize: 20,
color: gradient ? '#FFFFFF' : '#0052FF',
}}>
{icon}
</div>
<span style={{
fontSize: 13,
color: gradient ? 'rgba(255,255,255,0.8)' : '#64748B',
fontWeight: 500,
}}>
{title}
</span>
</div>
<div style={{
fontSize: 32,
fontWeight: 700,
fontFamily: '"Inter", system-ui, sans-serif',
color: gradient ? '#FFFFFF' : '#0F172A',
letterSpacing: '-0.02em',
lineHeight: 1.1,
}}>
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}
{suffix && (
<span style={{
fontSize: 14,
fontWeight: 500,
marginLeft: 4,
color: gradient ? 'rgba(255,255,255,0.7)' : '#94A3B8',
}}>
{suffix}
</span>
)}
</div>
</Card>
);
const DashboardPage: React.FC = () => { const DashboardPage: React.FC = () => {
const [summary, setSummary] = useState<any>({}); const [summary, setSummary] = useState<StockSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
getStockSummary().then((res) => { getStockSummary()
if (res?.code === 0) { .then((res) => {
setSummary(res.data); if (res?.code === 0) setSummary(res.data);
} })
}); .finally(() => setLoading(false));
}, []); }, []);
if (loading) {
return (
<PageContainer>
<div style={{ display: 'flex', justifyContent: 'center', padding: 120 }}>
<Spin size="large" />
</div>
</PageContainer>
);
}
return ( return (
<PageContainer> <PageContainer
<Row gutter={[16, 16]}> header={{
<Col span={4}> title: (
<StatisticCard <span style={{ fontFamily: '"Calistoga", Georgia, serif', fontSize: 24 }}>
statistic={{
title: '产品总数', </span>
value: summary.productCount || 0, ),
suffix: '种', }}
}} >
<Row gutter={[20, 20]}>
<Col xs={24} sm={12} lg={8}>
<StatCard
icon={<ShoppingOutlined />}
title="产品总数"
value={summary?.productCount ?? 0}
suffix="种"
gradient
/> />
</Col> </Col>
<Col span={5}> <Col xs={24} sm={12} lg={8}>
<StatisticCard <StatCard
statistic={{ icon={<InboxOutlined />}
title: '总匹数', title="总匹数"
value: summary.totalPieces || 0, value={summary?.totalPieces ?? 0}
suffix: '匹', suffix="匹"
}}
/> />
</Col> </Col>
<Col span={5}> <Col xs={24} sm={12} lg={8}>
<StatisticCard <StatCard
statistic={{ icon={<DatabaseOutlined />}
title: '总盘数', title="总盘数"
value: summary.totalRolls || 0, value={summary?.totalRolls ?? 0}
suffix: '盘', suffix="盘"
}}
/> />
</Col> </Col>
<Col span={5}> <Col xs={24} sm={12} lg={12}>
<StatisticCard <StatCard
statistic={{ icon={<DollarOutlined />}
title: '库存总值(成本)', title="库存总值(成本)"
value: summary.totalCostValue || '0.00', value={summary?.totalCostValue ?? '0.00'}
prefix: '¥', prefix="¥"
}}
/> />
</Col> </Col>
<Col span={5}> <Col xs={24} sm={12} lg={12}>
<StatisticCard <StatCard
statistic={{ icon={<RiseOutlined />}
title: '库存总值(销售)', title="库存总值(销售)"
value: summary.totalSalesValue || '0.00', value={summary?.totalSalesValue ?? '0.00'}
prefix: '¥', prefix="¥"
}}
/> />
</Col> </Col>
</Row> </Row>
{/* Quick navigation section */}
<div style={{ marginTop: 32 }}>
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
borderRadius: 24,
border: '1px solid rgba(0,82,255,0.2)',
background: 'rgba(0,82,255,0.04)',
padding: '8px 18px',
marginBottom: 20,
}}>
<span style={{
width: 7,
height: 7,
borderRadius: '50%',
background: '#0052FF',
}} />
<span style={{
fontFamily: '"JetBrains Mono", monospace',
fontSize: 11,
textTransform: 'uppercase' as const,
letterSpacing: '0.15em',
color: '#0052FF',
}}>
</span>
</div>
<Row gutter={[16, 16]}>
{[
{ title: '产品档案', desc: '管理产品信息', path: '/inventory/products', icon: <ShoppingOutlined /> },
{ title: '库存查询', desc: '实时库存数据', path: '/inventory/stocks', icon: <DatabaseOutlined /> },
{ title: '库存统计', desc: '分类统计分析', path: '/inventory/stats', icon: <RiseOutlined /> },
{ title: 'Excel导入', desc: '批量导入产品', path: '/inventory/import', icon: <InboxOutlined /> },
].map((item) => (
<Col xs={24} sm={12} lg={6} key={item.path}>
<Card
bordered={false}
hoverable
style={{
borderRadius: 14,
border: '1px solid #E2E8F0',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
styles={{ body: { padding: '20px 22px' } }}
onClick={() => window.location.assign(item.path)}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 14,
}}>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: 40,
height: 40,
borderRadius: 10,
background: 'linear-gradient(135deg, #0052FF, #4D7CFF)',
color: '#FFFFFF',
fontSize: 18,
}}>
{item.icon}
</div>
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: '#0F172A' }}>{item.title}</div>
<div style={{ fontSize: 12, color: '#94A3B8', marginTop: 2 }}>{item.desc}</div>
</div>
</div>
</Card>
</Col>
))}
</Row>
</div>
</PageContainer> </PageContainer>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -1,56 +1,108 @@
import { PageContainer, StatisticCard } from '@ant-design/pro-components'; import React, { useEffect, useState } from 'react';
import { Col, Row, Table } from 'antd'; import { PageContainer } from '@ant-design/pro-components';
import { useEffect, useState } from 'react'; import { Card, Col, Row, Table, Spin } from 'antd';
import {
ShoppingOutlined,
InboxOutlined,
DatabaseOutlined,
DollarOutlined,
RiseOutlined,
} from '@ant-design/icons';
import { getStockSummary, getStockByColor, getStockByLocation } from '@/services/api'; import { getStockSummary, getStockByColor, getStockByLocation } from '@/services/api';
import type { StockSummary, StockGroup } from '@/types/api';
import type { ColumnsType } from 'antd/es/table';
const groupColumns: ColumnsType<StockGroup> = [
{ title: '名称', dataIndex: 'name' },
{ title: '产品数', dataIndex: 'count' },
{ title: '库存量', dataIndex: 'quantity' },
];
const StatsPage: React.FC = () => { const StatsPage: React.FC = () => {
const [summary, setSummary] = useState<any>({}); const [summary, setSummary] = useState<StockSummary | null>(null);
const [colorData, setColorData] = useState<any[]>([]); const [colorData, setColorData] = useState<StockGroup[]>([]);
const [locationData, setLocationData] = useState<any[]>([]); const [locationData, setLocationData] = useState<StockGroup[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
getStockSummary().then((res) => res?.code === 0 && setSummary(res.data)); Promise.all([
getStockByColor().then((res) => res?.code === 0 && setColorData(res.data?.list || [])); getStockSummary(),
getStockByLocation().then((res) => res?.code === 0 && setLocationData(res.data?.list || [])); getStockByColor(),
getStockByLocation(),
]).then(([sumRes, colorRes, locRes]) => {
if (sumRes?.code === 0) setSummary(sumRes.data);
if (colorRes?.code === 0) setColorData(colorRes.data?.list || []);
if (locRes?.code === 0) setLocationData(locRes.data?.list || []);
}).finally(() => setLoading(false));
}, []); }, []);
if (loading) {
return (
<PageContainer>
<div style={{ display: 'flex', justifyContent: 'center', padding: 120 }}>
<Spin size="large" />
</div>
</PageContainer>
);
}
const stats = [
{ icon: <ShoppingOutlined />, title: '产品总数', value: summary?.productCount ?? 0 },
{ icon: <InboxOutlined />, title: '总匹数', value: summary?.totalPieces ?? 0 },
{ icon: <DatabaseOutlined />, title: '总盘数', value: summary?.totalRolls ?? 0 },
{ icon: <DollarOutlined />, title: '库存总值(成本)', value: `¥${summary?.totalCostValue ?? '0'}` },
{ icon: <RiseOutlined />, title: '库存总值(销售)', value: `¥${summary?.totalSalesValue ?? '0'}` },
];
return ( return (
<PageContainer> <PageContainer>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col span={5}><StatisticCard statistic={{ title: '产品总数', value: summary.productCount || 0 }} /></Col> {stats.map((s) => (
<Col span={5}><StatisticCard statistic={{ title: '总匹数', value: summary.totalPieces || 0 }} /></Col> <Col xs={24} sm={12} lg={4} xl={4} key={s.title} style={{ flex: 1 }}>
<Col span={5}><StatisticCard statistic={{ title: '总盘数', value: summary.totalRolls || 0 }} /></Col> <Card bordered={false} styles={{ body: { padding: '20px 18px' } }}>
<Col span={5}><StatisticCard statistic={{ title: '库存总值(成本)', value: summary.totalCostValue || '0', prefix: '¥' }} /></Col> <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<Col span={4}><StatisticCard statistic={{ title: '库存总值(销售)', value: summary.totalSalesValue || '0', prefix: '¥' }} /></Col> <span style={{
display: 'flex', justifyContent: 'center', alignItems: 'center',
width: 34, height: 34, borderRadius: 8,
background: 'rgba(0,82,255,0.06)', color: '#0052FF', fontSize: 16,
}}>
{s.icon}
</span>
<span style={{ fontSize: 12, color: '#64748B' }}>{s.title}</span>
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#0F172A' }}>{s.value}</div>
</Card>
</Col>
))}
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col xs={24} lg={12}>
<Table <Card title="按颜色分类统计" bordered={false}>
title={() => '按颜色分类统计'} <Table<StockGroup>
dataSource={colorData} dataSource={colorData}
rowKey="name" rowKey="name"
columns={[ columns={[
{ title: '颜色', dataIndex: 'name' }, { title: '颜色', dataIndex: 'name' },
{ title: '产品数', dataIndex: 'count' }, ...groupColumns.slice(1),
{ title: '库存量', dataIndex: 'quantity' }, ]}
]} pagination={false}
pagination={false} size="small"
size="small" />
/> </Card>
</Col> </Col>
<Col span={12}> <Col xs={24} lg={12}>
<Table <Card title="按位置分布统计" bordered={false}>
title={() => '按位置分布统计'} <Table<StockGroup>
dataSource={locationData} dataSource={locationData}
rowKey="name" rowKey="name"
columns={[ columns={[
{ title: '存放位置', dataIndex: 'name' }, { title: '存放位置', dataIndex: 'name' },
{ title: '产品数', dataIndex: 'count' }, ...groupColumns.slice(1),
{ title: '库存量', dataIndex: 'quantity' }, ]}
]} pagination={false}
pagination={false} size="small"
size="small" />
/> </Card>
</Col> </Col>
</Row> </Row>
</PageContainer> </PageContainer>

View File

@ -1,7 +1,8 @@
import { ProColumns, ProTable } from '@ant-design/pro-components'; import { ProColumns, ProTable } from '@ant-design/pro-components';
import { listProducts } from '@/services/api'; import { listProducts } from '@/services/api';
import type { Product } from '@/types/api';
const columns: ProColumns[] = [ const columns: ProColumns<Product>[] = [
{ title: '产品名称', dataIndex: 'productName', ellipsis: true }, { title: '产品名称', dataIndex: 'productName', ellipsis: true },
{ title: '规格型号', dataIndex: 'spec' }, { title: '规格型号', dataIndex: 'spec' },
{ title: '颜色', dataIndex: 'color' }, { title: '颜色', dataIndex: 'color' },
@ -14,7 +15,7 @@ const columns: ProColumns[] = [
]; ];
const StocksPage: React.FC = () => ( const StocksPage: React.FC = () => (
<ProTable <ProTable<Product>
headerTitle="库存查询" headerTitle="库存查询"
rowKey="productId" rowKey="productId"
columns={columns} columns={columns}

View File

@ -1,19 +1,24 @@
import { LoginForm, ProFormText } from '@ant-design/pro-components'; import React, { useState } from 'react';
import { history, useModel } from '@umijs/max'; import { history, useModel } from '@umijs/max';
import { message } from 'antd'; import { Button, Form, Input, message } from 'antd';
import { LockOutlined, UserOutlined, HomeOutlined } from '@ant-design/icons';
import { getUserInfo, login } from '@/services/api'; import { getUserInfo, login } from '@/services/api';
import type { LoginParams } from '@/types/api';
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const { setInitialState } = useModel('@@initialState'); const { setInitialState } = useModel('@@initialState');
const [loading, setLoading] = useState(false);
const handleSubmit = async (values: { username: string; password: string }) => { const handleSubmit = async (values: LoginParams) => {
setLoading(true);
try { try {
const res = await login(values); const res = await login(values);
if (res.code === 0) { if (res.code === 0) {
localStorage.setItem('token', res.data.token); localStorage.setItem('token', res.data.token);
localStorage.setItem(
// Important: load authorized menu paths immediately after login. 'tenantId',
// Otherwise, first render may temporarily show full menus until refresh. res.data.userInfo?.tenantId || values.tenantId || 't_default_001',
);
const infoRes = await getUserInfo(); const infoRes = await getUserInfo();
if (infoRes?.code === 0 && infoRes.data) { if (infoRes?.code === 0 && infoRes.data) {
await setInitialState({ await setInitialState({
@ -26,39 +31,206 @@ const LoginPage: React.FC = () => {
menuPaths: [], menuPaths: [],
}); });
} }
message.success('登录成功'); message.success('登录成功');
history.push('/dashboard'); history.push('/dashboard');
} else { } else {
message.error(res.msg || '登录失败'); message.error(res.msg || '登录失败');
} }
} catch (error) { } catch {
message.error('网络异常,请稍后重试'); message.error('网络异常,请稍后重试');
} finally {
setLoading(false);
} }
}; };
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#f0f2f5' }}> <div style={styles.wrapper}>
<LoginForm {/* Background decorations */}
title="muyuqingfeng仓储管理系统" <div style={styles.bgGlow1} />
subTitle="Warehouse Management System" <div style={styles.bgGlow2} />
onFinish={handleSubmit} <div style={styles.dotPattern} />
>
<ProFormText <div style={styles.card}>
name="username" {/* Logo area */}
fieldProps={{ size: 'large' }} <div style={styles.logoSection}>
placeholder="用户名" <div style={styles.logoIcon}>
rules={[{ required: true, message: '请输入用户名' }]} <span style={styles.logoLetter}>
/> <ruby><rt></rt></ruby>
<ProFormText.Password <ruby><rt></rt></ruby>
name="password" </span>
fieldProps={{ size: 'large' }} </div>
placeholder="密码" <h1 style={styles.title}>
rules={[{ required: true, message: '请输入密码' }]} <ruby><rt style={{ fontSize: 11, color: '#94A3B8', fontWeight: 400, letterSpacing: 4 }}></rt></ruby>
/> <ruby><rt style={{ fontSize: 11, color: '#94A3B8', fontWeight: 400, letterSpacing: 2 }}></rt></ruby>
</LoginForm> </h1>
<p style={styles.subtitle}></p>
</div>
<Form<LoginParams>
size="large"
onFinish={handleSubmit}
autoComplete="off"
style={{ width: '100%' }}
>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input
prefix={<UserOutlined style={{ color: '#94A3B8' }} />}
placeholder="用户名"
style={styles.input}
/>
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password
prefix={<LockOutlined style={{ color: '#94A3B8' }} />}
placeholder="密码"
style={styles.input}
/>
</Form.Item>
<Form.Item name="tenantId" rules={[{ required: true, message: '请输入租户ID' }]}>
<Input
prefix={<HomeOutlined style={{ color: '#94A3B8' }} />}
placeholder="租户ID必填"
style={styles.input}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={styles.submitBtn}
>
</Button>
</Form.Item>
</Form>
<p style={styles.footer}>
Warehouse Management System
</p>
</div>
</div> </div>
); );
}; };
const styles: Record<string, React.CSSProperties> = {
wrapper: {
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #0F172A 0%, #1E293B 50%, #0F172A 100%)',
overflow: 'hidden',
},
bgGlow1: {
position: 'absolute',
top: '-20%',
right: '-10%',
width: '600px',
height: '600px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(0,82,255,0.15) 0%, transparent 70%)',
filter: 'blur(80px)',
pointerEvents: 'none',
},
bgGlow2: {
position: 'absolute',
bottom: '-15%',
left: '-5%',
width: '500px',
height: '500px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(77,124,255,0.1) 0%, transparent 70%)',
filter: 'blur(80px)',
pointerEvents: 'none',
},
dotPattern: {
position: 'absolute',
inset: 0,
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.03) 1px, transparent 1px)',
backgroundSize: '32px 32px',
pointerEvents: 'none',
},
card: {
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
maxWidth: 420,
margin: '0 24px',
padding: '48px 40px 40px',
background: 'rgba(255,255,255,0.97)',
borderRadius: 20,
boxShadow: '0 20px 60px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.1)',
backdropFilter: 'blur(20px)',
},
logoSection: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: 36,
},
logoIcon: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: 80,
height: 80,
borderRadius: 18,
background: 'linear-gradient(135deg, #0052FF, #4D7CFF)',
boxShadow: '0 8px 24px rgba(0,82,255,0.35)',
marginBottom: 20,
},
logoLetter: {
fontSize: 24,
fontWeight: 700,
color: '#FFFFFF',
lineHeight: '36px',
},
title: {
margin: 0,
fontSize: 26,
fontWeight: 400,
fontFamily: '"Calistoga", Georgia, serif',
color: '#0F172A',
letterSpacing: '-0.01em',
},
subtitle: {
margin: '6px 0 0',
fontSize: 14,
color: '#64748B',
fontFamily: '"JetBrains Mono", monospace',
letterSpacing: '0.1em',
textTransform: 'uppercase' as const,
},
input: {
height: 48,
borderRadius: 12,
border: '1px solid #E2E8F0',
fontSize: 15,
},
submitBtn: {
height: 50,
borderRadius: 12,
fontSize: 16,
fontWeight: 600,
background: 'linear-gradient(135deg, #0052FF, #4D7CFF)',
border: 'none',
boxShadow: '0 4px 14px rgba(0,82,255,0.25)',
},
footer: {
marginTop: 24,
fontSize: 12,
color: '#94A3B8',
fontFamily: '"JetBrains Mono", monospace',
letterSpacing: '0.05em',
},
};
export default LoginPage; export default LoginPage;

View File

@ -1,10 +1,12 @@
import React, { useEffect, useState } from 'react';
import { PageContainer } from '@ant-design/pro-components'; import { PageContainer } from '@ant-design/pro-components';
import { Table, Input, Button, message, Card } from 'antd'; import { Table, Input, Button, message, Card, Space } from 'antd';
import { useEffect, useState } from 'react';
import { listConfigs, updateConfig } from '@/services/api'; import { listConfigs, updateConfig } from '@/services/api';
import type { SystemConfig } from '@/types/api';
import type { ColumnsType } from 'antd/es/table';
const ConfigsPage: React.FC = () => { const ConfigsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<SystemConfig[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editingKey, setEditingKey] = useState(''); const [editingKey, setEditingKey] = useState('');
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
@ -27,12 +29,14 @@ const ConfigsPage: React.FC = () => {
} }
}; };
const columns = [ const columns: ColumnsType<SystemConfig> = [
{ title: '配置名称', dataIndex: 'configName', key: 'configName' }, { title: '配置名称', dataIndex: 'configName', key: 'configName' },
{ title: '配置键', dataIndex: 'configKey', key: 'configKey' }, { title: '配置键', dataIndex: 'configKey', key: 'configKey' },
{ {
title: '配置值', dataIndex: 'configValue', key: 'configValue', title: '配置值',
render: (v: string, record: any) => dataIndex: 'configValue',
key: 'configValue',
render: (v, record) =>
editingKey === record.configKey editingKey === record.configKey
? <Input value={editValue} onChange={(e) => setEditValue(e.target.value)} style={{ width: 300 }} /> ? <Input value={editValue} onChange={(e) => setEditValue(e.target.value)} style={{ width: 300 }} />
: v, : v,
@ -40,21 +44,30 @@ const ConfigsPage: React.FC = () => {
{ title: '分组', dataIndex: 'configGroup', key: 'configGroup' }, { title: '分组', dataIndex: 'configGroup', key: 'configGroup' },
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
{ {
title: '操作', key: 'action', title: '操作',
render: (_: any, record: any) => key: 'action',
editingKey === record.configKey render: (_, record) =>
? <> editingKey === record.configKey ? (
<a onClick={() => handleSave(record.configKey)} style={{ marginRight: 8 }}></a> <Space>
<a onClick={() => setEditingKey('')}></a> <a onClick={() => handleSave(record.configKey)}></a>
</> <a onClick={() => setEditingKey('')}></a>
: <a onClick={() => { setEditingKey(record.configKey); setEditValue(record.configValue); }}></a>, </Space>
) : (
<a onClick={() => { setEditingKey(record.configKey); setEditValue(record.configValue); }}></a>
),
}, },
]; ];
return ( return (
<PageContainer> <PageContainer>
<Card> <Card bordered={false}>
<Table columns={columns} dataSource={data} rowKey="configKey" loading={loading} pagination={false} /> <Table<SystemConfig>
columns={columns}
dataSource={data}
rowKey="configKey"
loading={loading}
pagination={false}
/>
</Card> </Card>
</PageContainer> </PageContainer>
); );

View File

@ -1,8 +1,9 @@
import { ProColumns, ProTable } from '@ant-design/pro-components'; import { ProColumns, ProTable } from '@ant-design/pro-components';
import { Tag } from 'antd'; import { Tag } from 'antd';
import { listLogs } from '@/services/api'; import { listLogs } from '@/services/api';
import type { OperationLog } from '@/types/api';
const columns: ProColumns[] = [ const columns: ProColumns<OperationLog>[] = [
{ title: '操作人', dataIndex: 'username' }, { title: '操作人', dataIndex: 'username' },
{ title: '模块', dataIndex: 'module' }, { title: '模块', dataIndex: 'module' },
{ title: '操作', dataIndex: 'operation' }, { title: '操作', dataIndex: 'operation' },
@ -11,21 +12,26 @@ const columns: ProColumns[] = [
{ title: 'IP', dataIndex: 'ip', search: false }, { title: 'IP', dataIndex: 'ip', search: false },
{ title: '耗时(ms)', dataIndex: 'duration', search: false }, { title: '耗时(ms)', dataIndex: 'duration', search: false },
{ {
title: '结果', dataIndex: 'status', search: false, title: '结果',
dataIndex: 'status',
search: false,
render: (_, r) => <Tag color={r.status === 1 ? 'success' : 'error'}>{r.status === 1 ? '成功' : '失败'}</Tag>, render: (_, r) => <Tag color={r.status === 1 ? 'success' : 'error'}>{r.status === 1 ? '成功' : '失败'}</Tag>,
}, },
{ title: '操作时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false }, { title: '操作时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
]; ];
const LogsPage: React.FC = () => ( const LogsPage: React.FC = () => (
<ProTable <ProTable<OperationLog>
headerTitle="操作日志" headerTitle="操作日志"
rowKey="logId" rowKey="logId"
columns={columns} columns={columns}
request={async (params) => { request={async (params) => {
const res = await listLogs({ const res = await listLogs({
page: params.current, pageSize: params.pageSize, page: params.current,
username: params.username, module: params.module, operation: params.operation, 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 }; return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
}} }}

View File

@ -1,43 +1,19 @@
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components'; import { PageContainer } from '@ant-design/pro-components';
import { Button, Table, Tag, message, Popconfirm } from 'antd'; import { Button, Card, Table, Tag, message, Popconfirm } from 'antd';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { listMenus, deleteMenu } from '@/services/api'; import { listMenus, deleteMenu } from '@/services/api';
import type { Menu } from '@/types/api';
import type { ColumnsType } from 'antd/es/table';
const menuTypeMap: Record<number, { text: string; color: string }> = { const MENU_TYPE_MAP: Record<number, { text: string; color: string }> = {
1: { text: '目录', color: 'blue' }, 1: { text: '目录', color: 'blue' },
2: { text: '菜单', color: 'green' }, 2: { text: '菜单', color: 'green' },
3: { text: '按钮', color: 'orange' }, 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 MenusPage: React.FC = () => {
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<Menu[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fetchData = async () => { const fetchData = async () => {
@ -49,17 +25,52 @@ const MenusPage: React.FC = () => {
useEffect(() => { fetchData(); }, []); useEffect(() => { fetchData(); }, []);
const columns: ColumnsType<Menu> = [
{ 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 = MENU_TYPE_MAP[v] ?? MENU_TYPE_MAP[1];
return <Tag color={t.color}>{t.text}</Tag>;
},
},
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder' },
{
title: '操作',
key: 'action',
render: (_, record) => [
<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('删除成功'); fetchData(); }
}}
>
<a style={{ color: 'var(--ant-color-error)' }}></a>
</Popconfirm>,
],
},
];
return ( return (
<PageContainer> <PageContainer>
<Button type="primary" icon={<PlusOutlined />} style={{ marginBottom: 16 }}></Button> <Card bordered={false}>
<Table <Button type="primary" icon={<PlusOutlined />} style={{ marginBottom: 16 }}></Button>
columns={columns} <Table<Menu>
dataSource={data} columns={columns}
rowKey="menuId" dataSource={data}
loading={loading} rowKey="menuId"
childrenColumnName="children" loading={loading}
pagination={false} childrenColumnName="children"
/> pagination={false}
/>
</Card>
</PageContainer> </PageContainer>
); );
}; };

View File

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

View File

@ -1,31 +1,36 @@
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormSelect } from '@ant-design/pro-components'; import { ActionType, ModalForm, ProColumns, ProFormSelect, ProFormText, ProTable } from '@ant-design/pro-components';
import { Button, message, Popconfirm, Switch } from 'antd'; import { Button, message, Popconfirm, Switch } from 'antd';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { listUsers, createUser, updateUser, deleteUser, updateUserStatus, listRoles } from '@/services/api'; import { listUsers, createUser, updateUser, deleteUser, updateUserStatus, listRoles } from '@/services/api';
import type { User } from '@/types/api';
const UsersPage: React.FC = () => { const UsersPage: React.FC = () => {
const actionRef = useRef<ActionType>(); const actionRef = useRef<ActionType>();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [currentRow, setCurrentRow] = useState<any>(null); const [currentRow, setCurrentRow] = useState<User | null>(null);
const [roles, setRoles] = useState<any[]>([]); const [roleOptions, setRoleOptions] = useState<{ label: string; value: string }[]>([]);
const fetchRoles = async () => { const fetchRoles = async () => {
const res = await listRoles({ page: 1, pageSize: 100 }); const res = await listRoles({ page: 1, pageSize: 100 });
if (res?.code === 0) { if (res?.code === 0) {
setRoles(res.data?.list?.map((r: any) => ({ label: r.roleName, value: r.roleId })) || []); setRoleOptions(
res.data?.list?.map((r) => ({ label: r.roleName, value: r.roleId })) || [],
);
} }
}; };
const columns: ProColumns[] = [ const columns: ProColumns<User>[] = [
{ title: '用户名', dataIndex: 'username' }, { title: '用户名', dataIndex: 'username' },
{ title: '真实姓名', dataIndex: 'realName' }, { title: '真实姓名', dataIndex: 'realName' },
{ title: '手机号', dataIndex: 'phone', search: false }, { title: '手机号', dataIndex: 'phone', search: false },
{ title: '邮箱', dataIndex: 'email', search: false }, { title: '邮箱', dataIndex: 'email', search: false },
{ title: '角色', dataIndex: 'roleName', search: false }, { title: '角色', dataIndex: 'roleName', search: false },
{ {
title: '状态', dataIndex: 'status', search: false, title: '状态',
dataIndex: 'status',
search: false,
render: (_, record) => ( render: (_, record) => (
<Switch <Switch
checked={record.status === 1} checked={record.status === 1}
@ -38,14 +43,19 @@ const UsersPage: React.FC = () => {
}, },
{ title: '最后登录', dataIndex: 'lastLoginTime', valueType: 'dateTime', search: false }, { title: '最后登录', dataIndex: 'lastLoginTime', valueType: 'dateTime', search: false },
{ {
title: '操作', valueType: 'option', title: '操作',
valueType: 'option',
render: (_, record) => [ render: (_, record) => [
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); fetchRoles(); }}></a>, <a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); fetchRoles(); }}></a>,
<Popconfirm key="del" title="确认删除?" onConfirm={async () => { <Popconfirm
const res = await deleteUser(record.userId); key="del"
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); } title="确认删除?"
}}> onConfirm={async () => {
<a style={{ color: '#ff4d4f' }}></a> const res = await deleteUser(record.userId);
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
}}
>
<a style={{ color: 'var(--ant-color-error)' }}></a>
</Popconfirm>, </Popconfirm>,
], ],
}, },
@ -53,13 +63,18 @@ const UsersPage: React.FC = () => {
return ( return (
<> <>
<ProTable <ProTable<User>
headerTitle="用户管理" headerTitle="用户管理"
actionRef={actionRef} actionRef={actionRef}
rowKey="userId" rowKey="userId"
columns={columns} columns={columns}
request={async (params) => { request={async (params) => {
const res = await listUsers({ page: params.current, pageSize: params.pageSize, username: params.username, realName: params.realName }); 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 }; return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
}} }}
toolBarRender={() => [ toolBarRender={() => [
@ -75,11 +90,7 @@ const UsersPage: React.FC = () => {
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
onFinish={async (values) => { onFinish={async (values) => {
const res = await createUser(values); const res = await createUser(values);
if (res?.code === 0) { if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
message.success('创建成功');
actionRef.current?.reload();
return true;
}
message.error(res?.msg || '创建失败'); message.error(res?.msg || '创建失败');
return false; return false;
}} }}
@ -89,21 +100,18 @@ const UsersPage: React.FC = () => {
<ProFormText name="realName" label="真实姓名" rules={[{ required: true }]} /> <ProFormText name="realName" label="真实姓名" rules={[{ required: true }]} />
<ProFormText name="phone" label="手机号" /> <ProFormText name="phone" label="手机号" />
<ProFormText name="email" label="邮箱" /> <ProFormText name="email" label="邮箱" />
<ProFormSelect name="roleId" label="角色" options={roles} rules={[{ required: true }]} /> <ProFormSelect name="roleId" label="角色" options={roleOptions} rules={[{ required: true }]} />
</ModalForm> </ModalForm>
<ModalForm <ModalForm
title="编辑用户" title="编辑用户"
open={editOpen} open={editOpen}
onOpenChange={setEditOpen} onOpenChange={setEditOpen}
initialValues={currentRow} initialValues={currentRow ?? undefined}
onFinish={async (values) => { onFinish={async (values) => {
if (!currentRow) return false;
const res = await updateUser(currentRow.userId, values); const res = await updateUser(currentRow.userId, values);
if (res?.code === 0) { if (res?.code === 0) { message.success('更新成功'); actionRef.current?.reload(); return true; }
message.success('更新成功');
actionRef.current?.reload();
return true;
}
message.error(res?.msg || '更新失败'); message.error(res?.msg || '更新失败');
return false; return false;
}} }}
@ -111,7 +119,7 @@ const UsersPage: React.FC = () => {
<ProFormText name="realName" label="真实姓名" /> <ProFormText name="realName" label="真实姓名" />
<ProFormText name="phone" label="手机号" /> <ProFormText name="phone" label="手机号" />
<ProFormText name="email" label="邮箱" /> <ProFormText name="email" label="邮箱" />
<ProFormSelect name="roleId" label="角色" options={roles} /> <ProFormSelect name="roleId" label="角色" options={roleOptions} />
</ModalForm> </ModalForm>
</> </>
); );

View File

@ -1,177 +1,181 @@
import { request } from '@umijs/max'; import { request } from '@umijs/max';
import type {
ApiResponse,
CurrentUser,
LoginParams,
LoginResult,
PaginatedData,
PaginationParams,
Product,
StockSummary,
StockGroup,
StockCheck,
StockAdjust,
User,
Role,
Menu,
OperationLog,
SystemConfig,
Relation,
RelationHistory,
} from '@/types/api';
// ==================== Auth ==================== // ── Auth ──────────────────────────────────────────
export async function login(data: { username: string; password: string }) { export const login = (data: LoginParams) =>
return request('/api/v1/auth/login', { method: 'POST', data }); request<ApiResponse<LoginResult>>('/api/v1/auth/login', { method: 'POST', data });
}
export async function logout() { export const logout = () =>
return request('/api/v1/auth/logout', { method: 'POST' }); request<ApiResponse>('/api/v1/auth/logout', { method: 'POST' });
}
export async function getUserInfo() { export const getUserInfo = () =>
return request('/api/v1/auth/info', { method: 'GET' }); request<ApiResponse<CurrentUser>>('/api/v1/auth/info', { method: 'GET' });
}
export async function changePassword(data: { oldPassword: string; newPassword: string }) { export const changePassword = (data: { oldPassword: string; newPassword: string }) =>
return request('/api/v1/auth/password', { method: 'PUT', data }); request<ApiResponse>('/api/v1/auth/password', { method: 'PUT', data });
}
// ==================== Users ==================== // ── Users ─────────────────────────────────────────
export async function listUsers(params: any) { export const listUsers = (params: PaginationParams & { username?: string; realName?: string }) =>
return request('/api/v1/system/users', { method: 'GET', params }); request<ApiResponse<PaginatedData<User>>>('/api/v1/system/users', { method: 'GET', params });
}
export async function getUser(id: string) { export const getUser = (id: string) =>
return request(`/api/v1/system/users/${id}`, { method: 'GET' }); request<ApiResponse<User>>(`/api/v1/system/users/${id}`, { method: 'GET' });
}
export async function createUser(data: any) { export const createUser = (data: Partial<User> & { password?: string }) =>
return request('/api/v1/system/users', { method: 'POST', data }); request<ApiResponse>('/api/v1/system/users', { method: 'POST', data });
}
export async function updateUser(id: string, data: any) { export const updateUser = (id: string, data: Partial<User>) =>
return request(`/api/v1/system/users/${id}`, { method: 'PUT', data }); request<ApiResponse>(`/api/v1/system/users/${id}`, { method: 'PUT', data });
}
export async function deleteUser(id: string) { export const deleteUser = (id: string) =>
return request(`/api/v1/system/users/${id}`, { method: 'DELETE' }); request<ApiResponse>(`/api/v1/system/users/${id}`, { method: 'DELETE' });
}
export async function updateUserStatus(id: string, status: number) { export const updateUserStatus = (id: string, status: number) =>
return request(`/api/v1/system/users/${id}/status`, { method: 'PUT', data: { status } }); request<ApiResponse>(`/api/v1/system/users/${id}/status`, { method: 'PUT', data: { status } });
}
// ==================== Roles ==================== // ── Roles ─────────────────────────────────────────
export async function listRoles(params?: any) { export const listRoles = (params?: PaginationParams & { roleName?: string }) =>
return request('/api/v1/system/roles', { method: 'GET', params }); request<ApiResponse<PaginatedData<Role>>>('/api/v1/system/roles', { method: 'GET', params });
}
export async function createRole(data: any) { export const createRole = (data: Partial<Role>) =>
return request('/api/v1/system/roles', { method: 'POST', data }); request<ApiResponse>('/api/v1/system/roles', { method: 'POST', data });
}
export async function updateRole(id: string, data: any) { export const updateRole = (id: string, data: Partial<Role>) =>
return request(`/api/v1/system/roles/${id}`, { method: 'PUT', data }); request<ApiResponse>(`/api/v1/system/roles/${id}`, { method: 'PUT', data });
}
export async function deleteRole(id: string) { export const deleteRole = (id: string) =>
return request(`/api/v1/system/roles/${id}`, { method: 'DELETE' }); request<ApiResponse>(`/api/v1/system/roles/${id}`, { method: 'DELETE' });
}
export async function setRolePermissions(id: string, menuIds: string[]) { export const setRolePermissions = (id: string, menuIds: string[]) =>
return request(`/api/v1/system/roles/${id}/permissions`, { method: 'PUT', data: { menuIds } }); request<ApiResponse>(`/api/v1/system/roles/${id}/permissions`, { method: 'PUT', data: { menuIds } });
}
// ==================== Menus ==================== // ── Menus ─────────────────────────────────────────
export async function listMenus() { export const listMenus = () =>
return request('/api/v1/system/menus', { method: 'GET' }); request<ApiResponse<PaginatedData<Menu>>>('/api/v1/system/menus', { method: 'GET' });
}
export async function createMenu(data: any) { export const createMenu = (data: Partial<Menu>) =>
return request('/api/v1/system/menus', { method: 'POST', data }); request<ApiResponse>('/api/v1/system/menus', { method: 'POST', data });
}
export async function updateMenu(id: string, data: any) { export const updateMenu = (id: string, data: Partial<Menu>) =>
return request(`/api/v1/system/menus/${id}`, { method: 'PUT', data }); request<ApiResponse>(`/api/v1/system/menus/${id}`, { method: 'PUT', data });
}
export async function deleteMenu(id: string) { export const deleteMenu = (id: string) =>
return request(`/api/v1/system/menus/${id}`, { method: 'DELETE' }); request<ApiResponse>(`/api/v1/system/menus/${id}`, { method: 'DELETE' });
}
// ==================== Logs ==================== // ── Logs ──────────────────────────────────────────
export async function listLogs(params: any) { export const listLogs = (params: PaginationParams & { username?: string; module?: string; operation?: string }) =>
return request('/api/v1/system/logs', { method: 'GET', params }); request<ApiResponse<PaginatedData<OperationLog>>>('/api/v1/system/logs', { method: 'GET', params });
}
// ==================== Configs ==================== // ── Configs ───────────────────────────────────────
export async function listConfigs() { export const listConfigs = () =>
return request('/api/v1/system/configs', { method: 'GET' }); request<ApiResponse<PaginatedData<SystemConfig>>>('/api/v1/system/configs', { method: 'GET' });
}
export async function updateConfig(key: string, value: string) { export const updateConfig = (key: string, value: string) =>
return request(`/api/v1/system/configs/${key}`, { method: 'PUT', data: { configValue: value } }); request<ApiResponse>(`/api/v1/system/configs/${key}`, { method: 'PUT', data: { configValue: value } });
}
// ==================== Products ==================== // ── Products ──────────────────────────────────────
export async function listProducts(params: any) { export const listProducts = (params: PaginationParams & { productName?: string; spec?: string; color?: string; location?: string }) =>
return request('/api/v1/inventory/products', { method: 'GET', params }); request<ApiResponse<PaginatedData<Product>>>('/api/v1/inventory/products', { method: 'GET', params });
}
export async function getProduct(id: string) { export const getProduct = (id: string) =>
return request(`/api/v1/inventory/products/${id}`, { method: 'GET' }); request<ApiResponse<Product>>(`/api/v1/inventory/products/${id}`, { method: 'GET' });
}
export async function createProduct(data: any) { export const createProduct = (data: Partial<Product>) =>
return request('/api/v1/inventory/products', { method: 'POST', data }); request<ApiResponse>('/api/v1/inventory/products', { method: 'POST', data });
}
export async function updateProduct(id: string, data: any) { export const updateProduct = (id: string, data: Partial<Product>) =>
return request(`/api/v1/inventory/products/${id}`, { method: 'PUT', data }); request<ApiResponse>(`/api/v1/inventory/products/${id}`, { method: 'PUT', data });
}
export async function deleteProduct(id: string) { export const deleteProduct = (id: string) =>
return request(`/api/v1/inventory/products/${id}`, { method: 'DELETE' }); request<ApiResponse>(`/api/v1/inventory/products/${id}`, { method: 'DELETE' });
}
export async function exportProducts() { export const exportProducts = () =>
return request('/api/v1/inventory/products/export', { method: 'GET', responseType: 'blob' }); request('/api/v1/inventory/products/export', { method: 'GET', responseType: 'blob' });
}
// ==================== Stocks ==================== // ── Stocks ────────────────────────────────────────
export async function getStockSummary() { export const getStockSummary = () =>
return request('/api/v1/inventory/stocks/summary', { method: 'GET' }); request<ApiResponse<StockSummary>>('/api/v1/inventory/stocks/summary', { method: 'GET' });
}
export async function getStockByColor() { export const getStockByColor = () =>
return request('/api/v1/inventory/stocks/by-color', { method: 'GET' }); request<ApiResponse<PaginatedData<StockGroup>>>('/api/v1/inventory/stocks/by-color', { method: 'GET' });
}
export async function getStockByLocation() { export const getStockByLocation = () =>
return request('/api/v1/inventory/stocks/by-location', { method: 'GET' }); request<ApiResponse<PaginatedData<StockGroup>>>('/api/v1/inventory/stocks/by-location', { method: 'GET' });
}
// ==================== Stock Checks ==================== // ── Stock Checks ──────────────────────────────────
export async function listStockChecks(params: any) { export const listStockChecks = (params: PaginationParams) =>
return request('/api/v1/inventory/checks', { method: 'GET', params }); request<ApiResponse<PaginatedData<StockCheck>>>('/api/v1/inventory/checks', { method: 'GET', params });
}
export async function getStockCheck(id: string) { export const getStockCheck = (id: string) =>
return request(`/api/v1/inventory/checks/${id}`, { method: 'GET' }); request<ApiResponse<StockCheck>>(`/api/v1/inventory/checks/${id}`, { method: 'GET' });
}
export async function createStockCheck(data: any) { export const createStockCheck = (data: Partial<StockCheck>) =>
return request('/api/v1/inventory/checks', { method: 'POST', data }); request<ApiResponse>('/api/v1/inventory/checks', { method: 'POST', data });
}
export async function confirmStockCheck(id: string) { export const confirmStockCheck = (id: string) =>
return request(`/api/v1/inventory/checks/${id}/confirm`, { method: 'POST' }); request<ApiResponse>(`/api/v1/inventory/checks/${id}/confirm`, { method: 'POST' });
}
// ==================== Stock Adjusts ==================== // ── Stock Adjusts ─────────────────────────────────
export async function listStockAdjusts(params: any) { export const listStockAdjusts = (params: PaginationParams) =>
return request('/api/v1/inventory/adjusts', { method: 'GET', params }); request<ApiResponse<PaginatedData<StockAdjust>>>('/api/v1/inventory/adjusts', { method: 'GET', params });
}
export async function getStockAdjust(id: string) { export const getStockAdjust = (id: string) =>
return request(`/api/v1/inventory/adjusts/${id}`, { method: 'GET' }); request<ApiResponse<StockAdjust>>(`/api/v1/inventory/adjusts/${id}`, { method: 'GET' });
}
export async function createStockAdjust(data: any) { export const createStockAdjust = (data: Partial<StockAdjust>) =>
return request('/api/v1/inventory/adjusts', { method: 'POST', data }); request<ApiResponse>('/api/v1/inventory/adjusts', { method: 'POST', data });
}
export async function approveStockAdjust(id: string, action: number) { export const approveStockAdjust = (id: string, action: number) =>
return request(`/api/v1/inventory/adjusts/${id}/approve`, { method: 'POST', data: { action } }); request<ApiResponse>(`/api/v1/inventory/adjusts/${id}/approve`, { method: 'POST', data: { action } });
}
// ── CRM Relations ─────────────────────────────────
export const listUpstream = (depth = 1) =>
request<ApiResponse<PaginatedData<Relation>>>('/api/v1/crm/graph/upstream', { method: 'GET', params: { depth } });
export const listDownstream = (depth = 1) =>
request<ApiResponse<PaginatedData<Relation>>>('/api/v1/crm/graph/downstream', { method: 'GET', params: { depth } });
export const createRelation = (data: {
fromTenantId: string;
toTenantId: string;
relationType: string;
validFrom?: string;
validTo?: string;
}) =>
request<ApiResponse>('/api/v1/crm/relationships', { method: 'POST', data });
export const updateRelation = (id: string, data: { status: number; validFrom?: string; validTo?: string }) =>
request<ApiResponse>(`/api/v1/crm/relationships/${id}`, { method: 'PUT', data });
export const listRelationHistory = (params?: PaginationParams) =>
request<ApiResponse<PaginatedData<RelationHistory>>>('/api/v1/crm/relationships/history', { method: 'GET', params });

157
src/types/api.ts Normal file
View File

@ -0,0 +1,157 @@
export interface ApiResponse<T = unknown> {
code: number;
msg?: string;
data: T;
}
export interface PaginatedData<T> {
list: T[];
total: number;
}
export interface CurrentUser {
userId: string;
username: string;
realName: string;
phone?: string;
email?: string;
roleName?: string;
tenantId: string;
menuPaths: string[];
avatar?: string;
}
export interface LoginParams {
username: string;
password: string;
tenantId?: string;
}
export interface LoginResult {
token: string;
userInfo: CurrentUser;
}
export interface Product {
productId: string;
productName: string;
spec?: string;
color?: string;
unitPieces: number;
unitRolls: number;
stockQuantity: string;
location?: string;
costPrice: string;
salesPrice: string;
remark?: string;
createdAt: string;
updatedAt: string;
}
export interface StockSummary {
productCount: number;
totalPieces: number;
totalRolls: number;
totalCostValue: string;
totalSalesValue: string;
}
export interface StockGroup {
name: string;
count: number;
quantity: number;
}
export interface StockCheck {
checkId: string;
checkNo: string;
checkDate: string;
checker: string;
status: number;
createdAt: string;
}
export interface StockAdjust {
adjustId: string;
adjustNo: string;
adjustDate: string;
adjustReason: string;
operator: string;
status: number;
createdAt: string;
}
export interface User {
userId: string;
username: string;
realName: string;
phone?: string;
email?: string;
roleId?: string;
roleName?: string;
status: number;
lastLoginTime?: string;
}
export interface Role {
roleId: string;
roleName: string;
roleKey: string;
roleDesc?: string;
sortOrder: number;
createdAt: string;
}
export interface Menu {
menuId: string;
menuName: string;
path?: string;
icon?: string;
menuType: number;
sortOrder: number;
children?: Menu[];
}
export interface OperationLog {
logId: string;
username: string;
module: string;
operation: string;
method: string;
path: string;
ip: string;
duration: number;
status: number;
createdAt: string;
}
export interface SystemConfig {
configKey: string;
configName: string;
configValue: string;
configGroup?: string;
remark?: string;
}
export interface Relation {
relationshipId: string;
fromTenantName: string;
toTenantName: string;
relationType: string;
status: number;
validFrom?: string;
}
export interface RelationHistory {
relationshipId: string;
changeType: string;
beforeJson: string;
afterJson: string;
changedBy: string;
changedAt: string;
}
export interface PaginationParams {
page?: number;
pageSize?: number;
}

82
src/types/theme.ts Normal file
View File

@ -0,0 +1,82 @@
import type { ThemeConfig } from 'antd';
export const designTokens = {
colors: {
accent: '#0052FF',
accentSecondary: '#4D7CFF',
accentForeground: '#FFFFFF',
background: '#FAFAFA',
foreground: '#0F172A',
muted: '#F1F5F9',
mutedForeground: '#64748B',
border: '#E2E8F0',
card: '#FFFFFF',
},
fonts: {
display: '"Calistoga", Georgia, serif',
body: '"Inter", system-ui, sans-serif',
mono: '"JetBrains Mono", monospace',
},
shadows: {
sm: '0 1px 3px rgba(0,0,0,0.06)',
md: '0 4px 6px rgba(0,0,0,0.07)',
lg: '0 10px 15px rgba(0,0,0,0.08)',
xl: '0 20px 25px rgba(0,0,0,0.1)',
accent: '0 4px 14px rgba(0,82,255,0.25)',
accentLg: '0 8px 24px rgba(0,82,255,0.35)',
},
} as const;
export const antdTheme: ThemeConfig = {
token: {
colorPrimary: designTokens.colors.accent,
colorBgContainer: designTokens.colors.card,
colorBgLayout: designTokens.colors.background,
colorText: designTokens.colors.foreground,
colorTextSecondary: designTokens.colors.mutedForeground,
colorBorder: designTokens.colors.border,
colorBorderSecondary: designTokens.colors.border,
fontFamily: designTokens.fonts.body,
borderRadius: 10,
borderRadiusLG: 12,
wireframe: false,
colorLink: designTokens.colors.accent,
colorInfo: designTokens.colors.accent,
boxShadow: designTokens.shadows.md,
boxShadowSecondary: designTokens.shadows.lg,
},
components: {
Button: {
borderRadius: 10,
controlHeight: 40,
controlHeightLG: 48,
primaryShadow: designTokens.shadows.accent,
},
Card: {
borderRadiusLG: 16,
boxShadowTertiary: designTokens.shadows.sm,
},
Input: {
controlHeight: 42,
borderRadius: 10,
},
Select: {
controlHeight: 42,
borderRadius: 10,
},
Table: {
borderRadiusLG: 12,
headerBg: designTokens.colors.muted,
},
Menu: {
itemBorderRadius: 8,
subMenuItemBorderRadius: 8,
},
Modal: {
borderRadiusLG: 16,
},
Tag: {
borderRadiusSM: 6,
},
},
};