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:
parent
68fc27a5fd
commit
f61e216c43
88
.umirc.ts
88
.umirc.ts
@ -7,9 +7,12 @@ export default defineConfig({
|
||||
initialState: {},
|
||||
request: {},
|
||||
layout: {
|
||||
title: 'muyuqingfeng仓储管理系统',
|
||||
title: '木羽清风仓储系统',
|
||||
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: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8888',
|
||||
@ -32,41 +35,34 @@ export default defineConfig({
|
||||
component: './Dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
},
|
||||
{
|
||||
name: '客户关系',
|
||||
path: '/crm',
|
||||
icon: 'NodeIndexOutlined',
|
||||
routes: [
|
||||
{
|
||||
name: '供应商与客户',
|
||||
path: '/crm/relations',
|
||||
component: './CRM/Relations',
|
||||
},
|
||||
{
|
||||
name: '关系全景图',
|
||||
path: '/crm/graph',
|
||||
component: './CRM/Graph',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '库存管理',
|
||||
path: '/inventory',
|
||||
icon: 'DatabaseOutlined',
|
||||
routes: [
|
||||
{
|
||||
name: '产品档案',
|
||||
path: '/inventory/products',
|
||||
component: './Inventory/Products',
|
||||
},
|
||||
{
|
||||
name: '库存查询',
|
||||
path: '/inventory/stocks',
|
||||
component: './Inventory/Stocks',
|
||||
},
|
||||
{
|
||||
name: '库存统计',
|
||||
path: '/inventory/stats',
|
||||
component: './Inventory/Stats',
|
||||
},
|
||||
{
|
||||
name: '库存盘点',
|
||||
path: '/inventory/checks',
|
||||
component: './Inventory/Checks',
|
||||
},
|
||||
{
|
||||
name: '库存调整',
|
||||
path: '/inventory/adjusts',
|
||||
component: './Inventory/Adjusts',
|
||||
},
|
||||
{
|
||||
name: 'Excel导入',
|
||||
path: '/inventory/import',
|
||||
component: './Inventory/Import',
|
||||
},
|
||||
{ name: '产品档案', path: '/inventory/products', component: './Inventory/Products' },
|
||||
{ name: '库存查询', path: '/inventory/stocks', component: './Inventory/Stocks' },
|
||||
{ name: '库存统计', path: '/inventory/stats', component: './Inventory/Stats' },
|
||||
{ name: '库存盘点', path: '/inventory/checks', component: './Inventory/Checks' },
|
||||
{ name: '库存调整', path: '/inventory/adjusts', component: './Inventory/Adjusts' },
|
||||
{ name: 'Excel导入', path: '/inventory/import', component: './Inventory/Import' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -74,31 +70,11 @@ export default defineConfig({
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
routes: [
|
||||
{
|
||||
name: '用户管理',
|
||||
path: '/system/users',
|
||||
component: './System/Users',
|
||||
},
|
||||
{
|
||||
name: '角色管理',
|
||||
path: '/system/roles',
|
||||
component: './System/Roles',
|
||||
},
|
||||
{
|
||||
name: '菜单管理',
|
||||
path: '/system/menus',
|
||||
component: './System/Menus',
|
||||
},
|
||||
{
|
||||
name: '操作日志',
|
||||
path: '/system/logs',
|
||||
component: './System/Logs',
|
||||
},
|
||||
{
|
||||
name: '系统配置',
|
||||
path: '/system/configs',
|
||||
component: './System/Configs',
|
||||
},
|
||||
{ name: '用户管理', path: '/system/users', component: './System/Users' },
|
||||
{ name: '角色管理', path: '/system/roles', component: './System/Roles' },
|
||||
{ name: '菜单管理', path: '/system/menus', component: './System/Menus' },
|
||||
{ name: '操作日志', path: '/system/logs', component: './System/Logs' },
|
||||
{ name: '系统配置', path: '/system/configs', component: './System/Configs' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
115
src/app.ts
115
src/app.ts
@ -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
148
src/app.tsx
Normal 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
93
src/global.css
Normal 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;
|
||||
}
|
||||
}
|
||||
446
src/pages/CRM/Graph/index.tsx
Normal file
446
src/pages/CRM/Graph/index.tsx
Normal 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;
|
||||
195
src/pages/CRM/Relations/index.tsx
Normal file
195
src/pages/CRM/Relations/index.tsx
Normal 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;
|
||||
@ -1,70 +1,250 @@
|
||||
import { PageContainer, StatisticCard } from '@ant-design/pro-components';
|
||||
import { Col, Row } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Card, Col, Row, Spin } from 'antd';
|
||||
import {
|
||||
ShoppingOutlined,
|
||||
InboxOutlined,
|
||||
DatabaseOutlined,
|
||||
DollarOutlined,
|
||||
RiseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
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 [summary, setSummary] = useState<any>({});
|
||||
const [summary, setSummary] = useState<StockSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getStockSummary().then((res) => {
|
||||
if (res?.code === 0) {
|
||||
setSummary(res.data);
|
||||
}
|
||||
});
|
||||
getStockSummary()
|
||||
.then((res) => {
|
||||
if (res?.code === 0) setSummary(res.data);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={4}>
|
||||
<StatisticCard
|
||||
statistic={{
|
||||
title: '产品总数',
|
||||
value: summary.productCount || 0,
|
||||
suffix: '种',
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 120 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
header={{
|
||||
title: (
|
||||
<span style={{ fontFamily: '"Calistoga", Georgia, serif', fontSize: 24 }}>
|
||||
数据总览
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<StatCard
|
||||
icon={<ShoppingOutlined />}
|
||||
title="产品总数"
|
||||
value={summary?.productCount ?? 0}
|
||||
suffix="种"
|
||||
gradient
|
||||
/>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<StatisticCard
|
||||
statistic={{
|
||||
title: '总匹数',
|
||||
value: summary.totalPieces || 0,
|
||||
suffix: '匹',
|
||||
}}
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<StatCard
|
||||
icon={<InboxOutlined />}
|
||||
title="总匹数"
|
||||
value={summary?.totalPieces ?? 0}
|
||||
suffix="匹"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<StatisticCard
|
||||
statistic={{
|
||||
title: '总盘数',
|
||||
value: summary.totalRolls || 0,
|
||||
suffix: '盘',
|
||||
}}
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<StatCard
|
||||
icon={<DatabaseOutlined />}
|
||||
title="总盘数"
|
||||
value={summary?.totalRolls ?? 0}
|
||||
suffix="盘"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<StatisticCard
|
||||
statistic={{
|
||||
title: '库存总值(成本)',
|
||||
value: summary.totalCostValue || '0.00',
|
||||
prefix: '¥',
|
||||
}}
|
||||
<Col xs={24} sm={12} lg={12}>
|
||||
<StatCard
|
||||
icon={<DollarOutlined />}
|
||||
title="库存总值(成本)"
|
||||
value={summary?.totalCostValue ?? '0.00'}
|
||||
prefix="¥"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<StatisticCard
|
||||
statistic={{
|
||||
title: '库存总值(销售)',
|
||||
value: summary.totalSalesValue || '0.00',
|
||||
prefix: '¥',
|
||||
}}
|
||||
<Col xs={24} sm={12} lg={12}>
|
||||
<StatCard
|
||||
icon={<RiseOutlined />}
|
||||
title="库存总值(销售)"
|
||||
value={summary?.totalSalesValue ?? '0.00'}
|
||||
prefix="¥"
|
||||
/>
|
||||
</Col>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,22 +2,25 @@ import { PlusOutlined } from '@ant-design/icons';
|
||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { Button, Tag } from 'antd';
|
||||
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' },
|
||||
1: { text: '已审核', color: 'success' },
|
||||
2: { text: '已驳回', color: 'error' },
|
||||
};
|
||||
|
||||
const columns: ProColumns[] = [
|
||||
const columns: ProColumns<StockAdjust>[] = [
|
||||
{ title: '调整单号', dataIndex: 'adjustNo' },
|
||||
{ title: '调整日期', dataIndex: 'adjustDate', valueType: 'date', search: false },
|
||||
{ title: '调整原因', dataIndex: 'adjustReason', search: false },
|
||||
{ title: '操作人', dataIndex: 'operator', search: false },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', search: false,
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
search: false,
|
||||
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>;
|
||||
},
|
||||
},
|
||||
@ -26,21 +29,14 @@ const columns: ProColumns[] = [
|
||||
];
|
||||
|
||||
const AdjustsPage: React.FC = () => (
|
||||
<ProTable
|
||||
<ProTable<StockAdjust>
|
||||
headerTitle="库存调整"
|
||||
rowKey="adjustId"
|
||||
columns={columns}
|
||||
search={false}
|
||||
request={async (params) => {
|
||||
const res = await listStockAdjusts({
|
||||
page: params.current,
|
||||
pageSize: params.pageSize,
|
||||
});
|
||||
return {
|
||||
data: res?.data?.list || [],
|
||||
total: res?.data?.total || 0,
|
||||
success: res?.code === 0,
|
||||
};
|
||||
const res = await listStockAdjusts({ page: params.current, pageSize: params.pageSize });
|
||||
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />}>创建调整单</Button>,
|
||||
|
||||
@ -2,21 +2,24 @@ import { PlusOutlined } from '@ant-design/icons';
|
||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { Button, Tag } from 'antd';
|
||||
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' },
|
||||
1: { text: '进行中', color: 'processing' },
|
||||
2: { text: '已完成', color: 'success' },
|
||||
};
|
||||
|
||||
const columns: ProColumns[] = [
|
||||
const columns: ProColumns<StockCheck>[] = [
|
||||
{ title: '盘点单号', dataIndex: 'checkNo' },
|
||||
{ title: '盘点日期', dataIndex: 'checkDate', valueType: 'date', search: false },
|
||||
{ title: '盘点人', dataIndex: 'checker', search: false },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', search: false,
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
search: false,
|
||||
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>;
|
||||
},
|
||||
},
|
||||
@ -25,21 +28,14 @@ const columns: ProColumns[] = [
|
||||
];
|
||||
|
||||
const ChecksPage: React.FC = () => (
|
||||
<ProTable
|
||||
<ProTable<StockCheck>
|
||||
headerTitle="库存盘点"
|
||||
rowKey="checkId"
|
||||
columns={columns}
|
||||
search={false}
|
||||
request={async (params) => {
|
||||
const res = await listStockChecks({
|
||||
page: params.current,
|
||||
pageSize: params.pageSize,
|
||||
});
|
||||
return {
|
||||
data: res?.data?.list || [],
|
||||
total: res?.data?.total || 0,
|
||||
success: res?.code === 0,
|
||||
};
|
||||
const res = await listStockChecks({ page: params.current, pageSize: params.pageSize });
|
||||
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />}>创建盘点单</Button>,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
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;
|
||||
|
||||
@ -9,17 +10,15 @@ const ImportPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Card title="Excel 批量导入">
|
||||
<Card title="Excel 批量导入" bordered={false}>
|
||||
<Space direction="vertical" size={20} style={{ display: 'flex' }}>
|
||||
<Alert
|
||||
message="导入说明"
|
||||
description="请按照模板格式准备 Excel 文件,必填字段:产品名称、库存数量。支持 .xlsx 和 .xls 格式。"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
<Button icon={<DownloadOutlined />} style={{ marginBottom: 16 }}>
|
||||
下载导入模板
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />}>下载导入模板</Button>
|
||||
<Dragger
|
||||
name="file"
|
||||
action="/api/v1/inventory/products/import"
|
||||
@ -42,6 +41,7 @@ const ImportPage: React.FC = () => {
|
||||
<p className="ant-upload-text">点击或拖拽文件到此处上传</p>
|
||||
<p className="ant-upload-hint">支持 .xlsx、.xls、.csv 格式</p>
|
||||
</Dragger>
|
||||
</Space>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@ -1,39 +1,9 @@
|
||||
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 { useRef, useState } from 'react';
|
||||
import { listProducts, createProduct, updateProduct, deleteProduct } from '@/services/api';
|
||||
|
||||
const ProductsPage: React.FC = () => {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [currentRow, setCurrentRow] = useState<any>(null);
|
||||
|
||||
const columns: ProColumns[] = [
|
||||
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
|
||||
{ title: '规格型号', dataIndex: 'spec', ellipsis: true },
|
||||
{ title: '颜色', dataIndex: 'color' },
|
||||
{ title: '匹数', dataIndex: 'unitPieces', search: false },
|
||||
{ title: '盘数', dataIndex: 'unitRolls', search: false },
|
||||
{ title: '库存数量', dataIndex: 'stockQuantity', search: false },
|
||||
{ title: '存放位置', dataIndex: 'location' },
|
||||
{ title: '成本价', dataIndex: 'costPrice', search: false, valueType: 'money' },
|
||||
{ title: '销售价', dataIndex: 'salesPrice', search: false, valueType: 'money' },
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', valueType: 'dateTime', search: false },
|
||||
{
|
||||
title: '操作', valueType: 'option',
|
||||
render: (_, record) => [
|
||||
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); }}>编辑</a>,
|
||||
<Popconfirm key="delete" title="确认删除?" onConfirm={async () => {
|
||||
const res = await deleteProduct(record.productId);
|
||||
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
|
||||
}}>
|
||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||
</Popconfirm>,
|
||||
],
|
||||
},
|
||||
];
|
||||
import type { Product } from '@/types/api';
|
||||
|
||||
const productFormFields = (
|
||||
<>
|
||||
@ -50,41 +20,99 @@ const ProductsPage: React.FC = () => {
|
||||
</>
|
||||
);
|
||||
|
||||
const ProductsPage: React.FC = () => {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [currentRow, setCurrentRow] = useState<Product | null>(null);
|
||||
|
||||
const columns: ProColumns<Product>[] = [
|
||||
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
|
||||
{ title: '规格型号', dataIndex: 'spec', ellipsis: true },
|
||||
{ title: '颜色', dataIndex: 'color' },
|
||||
{ title: '匹数', dataIndex: 'unitPieces', search: false },
|
||||
{ title: '盘数', dataIndex: 'unitRolls', search: false },
|
||||
{ title: '库存数量', dataIndex: 'stockQuantity', search: false },
|
||||
{ title: '存放位置', dataIndex: 'location' },
|
||||
{ title: '成本价', dataIndex: 'costPrice', search: false, valueType: 'money' },
|
||||
{ title: '销售价', dataIndex: 'salesPrice', search: false, valueType: 'money' },
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', valueType: 'dateTime', search: false },
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
render: (_, record) => [
|
||||
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); }}>编辑</a>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
const res = await deleteProduct(record.productId);
|
||||
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
|
||||
}}
|
||||
>
|
||||
<a style={{ color: 'var(--ant-color-error)' }}>删除</a>
|
||||
</Popconfirm>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeValues = (values: Record<string, any>) => ({
|
||||
...values,
|
||||
stockQuantity: String(values.stockQuantity || '0'),
|
||||
costPrice: String(values.costPrice || '0'),
|
||||
salesPrice: String(values.salesPrice || '0'),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProTable
|
||||
<ProTable<Product>
|
||||
headerTitle="产品档案"
|
||||
actionRef={actionRef}
|
||||
rowKey="productId"
|
||||
columns={columns}
|
||||
request={async (params) => {
|
||||
const res = await listProducts({
|
||||
page: params.current, pageSize: params.pageSize,
|
||||
productName: params.productName, spec: params.spec,
|
||||
color: params.color, location: params.location,
|
||||
page: params.current,
|
||||
pageSize: params.pageSize,
|
||||
productName: params.productName,
|
||||
spec: params.spec,
|
||||
color: params.color,
|
||||
location: params.location,
|
||||
});
|
||||
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新增产品</Button>,
|
||||
<Button key="import" icon={<UploadOutlined />} onClick={() => window.location.href = '/inventory/import'}>Excel导入</Button>,
|
||||
<Button key="import" icon={<UploadOutlined />} onClick={() => window.location.assign('/inventory/import')}>Excel导入</Button>,
|
||||
<Button key="export" icon={<DownloadOutlined />}>Excel导出</Button>,
|
||||
]}
|
||||
/>
|
||||
<ModalForm title="新增产品" open={createOpen} onOpenChange={setCreateOpen}
|
||||
<ModalForm
|
||||
title="新增产品"
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onFinish={async (values) => {
|
||||
const res = await createProduct({ ...values, stockQuantity: String(values.stockQuantity || '0'), costPrice: String(values.costPrice || '0'), salesPrice: String(values.salesPrice || '0') });
|
||||
const res = await createProduct(normalizeValues(values));
|
||||
if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
|
||||
message.error(res?.msg || '创建失败'); return false;
|
||||
}}>
|
||||
message.error(res?.msg || '创建失败');
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{productFormFields}
|
||||
</ModalForm>
|
||||
<ModalForm title="编辑产品" open={editOpen} onOpenChange={setEditOpen} initialValues={currentRow}
|
||||
<ModalForm
|
||||
title="编辑产品"
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
initialValues={currentRow ?? undefined}
|
||||
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; }
|
||||
message.error(res?.msg || '更新失败'); return false;
|
||||
}}>
|
||||
message.error(res?.msg || '更新失败');
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{productFormFields}
|
||||
</ModalForm>
|
||||
</>
|
||||
|
||||
@ -1,56 +1,108 @@
|
||||
import { PageContainer, StatisticCard } from '@ant-design/pro-components';
|
||||
import { Col, Row, Table } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
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 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 [summary, setSummary] = useState<any>({});
|
||||
const [colorData, setColorData] = useState<any[]>([]);
|
||||
const [locationData, setLocationData] = useState<any[]>([]);
|
||||
const [summary, setSummary] = useState<StockSummary | null>(null);
|
||||
const [colorData, setColorData] = useState<StockGroup[]>([]);
|
||||
const [locationData, setLocationData] = useState<StockGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getStockSummary().then((res) => res?.code === 0 && setSummary(res.data));
|
||||
getStockByColor().then((res) => res?.code === 0 && setColorData(res.data?.list || []));
|
||||
getStockByLocation().then((res) => res?.code === 0 && setLocationData(res.data?.list || []));
|
||||
Promise.all([
|
||||
getStockSummary(),
|
||||
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 (
|
||||
<PageContainer>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col span={5}><StatisticCard statistic={{ title: '产品总数', value: summary.productCount || 0 }} /></Col>
|
||||
<Col span={5}><StatisticCard statistic={{ title: '总匹数', value: summary.totalPieces || 0 }} /></Col>
|
||||
<Col span={5}><StatisticCard statistic={{ title: '总盘数', value: summary.totalRolls || 0 }} /></Col>
|
||||
<Col span={5}><StatisticCard statistic={{ title: '库存总值(成本)', value: summary.totalCostValue || '0', prefix: '¥' }} /></Col>
|
||||
<Col span={4}><StatisticCard statistic={{ title: '库存总值(销售)', value: summary.totalSalesValue || '0', prefix: '¥' }} /></Col>
|
||||
{stats.map((s) => (
|
||||
<Col xs={24} sm={12} lg={4} xl={4} key={s.title} style={{ flex: 1 }}>
|
||||
<Card bordered={false} styles={{ body: { padding: '20px 18px' } }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<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 gutter={16}>
|
||||
<Col span={12}>
|
||||
<Table
|
||||
title={() => '按颜色分类统计'}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="按颜色分类统计" bordered={false}>
|
||||
<Table<StockGroup>
|
||||
dataSource={colorData}
|
||||
rowKey="name"
|
||||
columns={[
|
||||
{ title: '颜色', dataIndex: 'name' },
|
||||
{ title: '产品数', dataIndex: 'count' },
|
||||
{ title: '库存量', dataIndex: 'quantity' },
|
||||
...groupColumns.slice(1),
|
||||
]}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Table
|
||||
title={() => '按位置分布统计'}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="按位置分布统计" bordered={false}>
|
||||
<Table<StockGroup>
|
||||
dataSource={locationData}
|
||||
rowKey="name"
|
||||
columns={[
|
||||
{ title: '存放位置', dataIndex: 'name' },
|
||||
{ title: '产品数', dataIndex: 'count' },
|
||||
{ title: '库存量', dataIndex: 'quantity' },
|
||||
...groupColumns.slice(1),
|
||||
]}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</PageContainer>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
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: 'spec' },
|
||||
{ title: '颜色', dataIndex: 'color' },
|
||||
@ -14,7 +15,7 @@ const columns: ProColumns[] = [
|
||||
];
|
||||
|
||||
const StocksPage: React.FC = () => (
|
||||
<ProTable
|
||||
<ProTable<Product>
|
||||
headerTitle="库存查询"
|
||||
rowKey="productId"
|
||||
columns={columns}
|
||||
|
||||
@ -1,19 +1,24 @@
|
||||
import { LoginForm, ProFormText } from '@ant-design/pro-components';
|
||||
import React, { useState } from 'react';
|
||||
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 type { LoginParams } from '@/types/api';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
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 {
|
||||
const res = await login(values);
|
||||
if (res.code === 0) {
|
||||
localStorage.setItem('token', res.data.token);
|
||||
|
||||
// Important: load authorized menu paths immediately after login.
|
||||
// Otherwise, first render may temporarily show full menus until refresh.
|
||||
localStorage.setItem(
|
||||
'tenantId',
|
||||
res.data.userInfo?.tenantId || values.tenantId || 't_default_001',
|
||||
);
|
||||
const infoRes = await getUserInfo();
|
||||
if (infoRes?.code === 0 && infoRes.data) {
|
||||
await setInitialState({
|
||||
@ -26,39 +31,206 @@ const LoginPage: React.FC = () => {
|
||||
menuPaths: [],
|
||||
});
|
||||
}
|
||||
|
||||
message.success('登录成功');
|
||||
history.push('/dashboard');
|
||||
} else {
|
||||
message.error(res.msg || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
message.error('网络异常,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#f0f2f5' }}>
|
||||
<LoginForm
|
||||
title="muyuqingfeng仓储管理系统"
|
||||
subTitle="Warehouse Management System"
|
||||
<div style={styles.wrapper}>
|
||||
{/* Background decorations */}
|
||||
<div style={styles.bgGlow1} />
|
||||
<div style={styles.bgGlow2} />
|
||||
<div style={styles.dotPattern} />
|
||||
|
||||
<div style={styles.card}>
|
||||
{/* Logo area */}
|
||||
<div style={styles.logoSection}>
|
||||
<div style={styles.logoIcon}>
|
||||
<span style={styles.logoLetter}>
|
||||
<ruby>木<rt>キ</rt></ruby>
|
||||
<ruby>羽<rt>ハネ</rt></ruby>
|
||||
</span>
|
||||
</div>
|
||||
<h1 style={styles.title}>
|
||||
<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>
|
||||
</h1>
|
||||
<p style={styles.subtitle}>仓储管理系统</p>
|
||||
</div>
|
||||
|
||||
<Form<LoginParams>
|
||||
size="large"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<ProFormText
|
||||
name="username"
|
||||
fieldProps={{ size: 'large' }}
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: '#94A3B8' }} />}
|
||||
placeholder="用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
style={styles.input}
|
||||
/>
|
||||
<ProFormText.Password
|
||||
name="password"
|
||||
fieldProps={{ size: 'large' }}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: '#94A3B8' }} />}
|
||||
placeholder="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
style={styles.input}
|
||||
/>
|
||||
</LoginForm>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Table, Input, Button, message, Card } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Table, Input, Button, message, Card, Space } from 'antd';
|
||||
import { listConfigs, updateConfig } from '@/services/api';
|
||||
import type { SystemConfig } from '@/types/api';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const ConfigsPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [data, setData] = useState<SystemConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingKey, setEditingKey] = 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: 'configKey', key: 'configKey' },
|
||||
{
|
||||
title: '配置值', dataIndex: 'configValue', key: 'configValue',
|
||||
render: (v: string, record: any) =>
|
||||
title: '配置值',
|
||||
dataIndex: 'configValue',
|
||||
key: 'configValue',
|
||||
render: (v, record) =>
|
||||
editingKey === record.configKey
|
||||
? <Input value={editValue} onChange={(e) => setEditValue(e.target.value)} style={{ width: 300 }} />
|
||||
: v,
|
||||
@ -40,21 +44,30 @@ const ConfigsPage: React.FC = () => {
|
||||
{ title: '分组', dataIndex: 'configGroup', key: 'configGroup' },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||
{
|
||||
title: '操作', key: 'action',
|
||||
render: (_: any, record: any) =>
|
||||
editingKey === record.configKey
|
||||
? <>
|
||||
<a onClick={() => handleSave(record.configKey)} style={{ marginRight: 8 }}>保存</a>
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_, record) =>
|
||||
editingKey === record.configKey ? (
|
||||
<Space>
|
||||
<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 (
|
||||
<PageContainer>
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={data} rowKey="configKey" loading={loading} pagination={false} />
|
||||
<Card bordered={false}>
|
||||
<Table<SystemConfig>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="configKey"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { Tag } from 'antd';
|
||||
import { listLogs } from '@/services/api';
|
||||
import type { OperationLog } from '@/types/api';
|
||||
|
||||
const columns: ProColumns[] = [
|
||||
const columns: ProColumns<OperationLog>[] = [
|
||||
{ title: '操作人', dataIndex: 'username' },
|
||||
{ title: '模块', dataIndex: 'module' },
|
||||
{ title: '操作', dataIndex: 'operation' },
|
||||
@ -11,21 +12,26 @@ const columns: ProColumns[] = [
|
||||
{ title: 'IP', dataIndex: 'ip', 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>,
|
||||
},
|
||||
{ title: '操作时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
|
||||
];
|
||||
|
||||
const LogsPage: React.FC = () => (
|
||||
<ProTable
|
||||
<ProTable<OperationLog>
|
||||
headerTitle="操作日志"
|
||||
rowKey="logId"
|
||||
columns={columns}
|
||||
request={async (params) => {
|
||||
const res = await listLogs({
|
||||
page: params.current, pageSize: params.pageSize,
|
||||
username: params.username, module: params.module, operation: params.operation,
|
||||
page: params.current,
|
||||
pageSize: params.pageSize,
|
||||
username: params.username,
|
||||
module: params.module,
|
||||
operation: params.operation,
|
||||
});
|
||||
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||
}}
|
||||
|
||||
@ -1,43 +1,19 @@
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
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 { 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' },
|
||||
2: { text: '菜单', color: 'green' },
|
||||
3: { text: '按钮', color: 'orange' },
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '菜单名称', dataIndex: 'menuName', key: 'menuName' },
|
||||
{ title: '路由', dataIndex: 'path', key: 'path' },
|
||||
{ title: '图标', dataIndex: 'icon', key: 'icon' },
|
||||
{
|
||||
title: '类型', dataIndex: 'menuType', key: 'menuType',
|
||||
render: (v: number) => {
|
||||
const t = menuTypeMap[v] || menuTypeMap[1];
|
||||
return <Tag color={t.color}>{t.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder' },
|
||||
{
|
||||
title: '操作', key: 'action',
|
||||
render: (_: any, record: any) => [
|
||||
<a key="edit" style={{ marginRight: 8 }}>编辑</a>,
|
||||
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
|
||||
const res = await deleteMenu(record.menuId);
|
||||
if (res?.code === 0) message.success('删除成功');
|
||||
}}>
|
||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||
</Popconfirm>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MenusPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [data, setData] = useState<Menu[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
@ -49,10 +25,44 @@ const MenusPage: React.FC = () => {
|
||||
|
||||
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 (
|
||||
<PageContainer>
|
||||
<Card bordered={false}>
|
||||
<Button type="primary" icon={<PlusOutlined />} style={{ marginBottom: 16 }}>新增菜单</Button>
|
||||
<Table
|
||||
<Table<Menu>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="menuId"
|
||||
@ -60,6 +70,7 @@ const MenusPage: React.FC = () => {
|
||||
childrenColumnName="children"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,38 +1,53 @@
|
||||
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 { useRef, useState } from 'react';
|
||||
import { listRoles, createRole, updateRole, deleteRole } from '@/services/api';
|
||||
import type { Role } from '@/types/api';
|
||||
|
||||
const RolesPage: React.FC = () => {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [currentRow, setCurrentRow] = useState<any>(null);
|
||||
const [currentRow, setCurrentRow] = useState<Role | null>(null);
|
||||
|
||||
const columns: ProColumns[] = [
|
||||
const columns: ProColumns<Role>[] = [
|
||||
{ title: '角色名称', dataIndex: 'roleName' },
|
||||
{ title: '角色标识', dataIndex: 'roleKey', search: false },
|
||||
{ title: '描述', dataIndex: 'roleDesc', search: false, ellipsis: true },
|
||||
{ title: '排序', dataIndex: 'sortOrder', search: false },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
|
||||
{
|
||||
title: '操作', valueType: 'option',
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
render: (_, record) => [
|
||||
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); }}>编辑</a>,
|
||||
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
|
||||
<Popconfirm
|
||||
key="del"
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
const res = await deleteRole(record.roleId);
|
||||
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
|
||||
}}>
|
||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||
}}
|
||||
>
|
||||
<a style={{ color: 'var(--ant-color-error)' }}>删除</a>
|
||||
</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 (
|
||||
<>
|
||||
<ProTable
|
||||
<ProTable<Role>
|
||||
headerTitle="角色管理"
|
||||
actionRef={actionRef}
|
||||
rowKey="roleId"
|
||||
@ -45,27 +60,33 @@ const RolesPage: React.FC = () => {
|
||||
<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) => {
|
||||
const res = await createRole(values);
|
||||
if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
|
||||
message.error(res?.msg || '创建失败'); return false;
|
||||
}}>
|
||||
<ProFormText name="roleName" label="角色名称" rules={[{ required: true }]} />
|
||||
<ProFormText name="roleKey" label="角色标识" rules={[{ required: true }]} placeholder="如: admin, warehouse, user" />
|
||||
<ProFormTextArea name="roleDesc" label="描述" />
|
||||
<ProFormDigit name="sortOrder" label="排序" initialValue={0} />
|
||||
message.error(res?.msg || '创建失败');
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{formFields}
|
||||
</ModalForm>
|
||||
<ModalForm title="编辑角色" open={editOpen} onOpenChange={setEditOpen} initialValues={currentRow}
|
||||
<ModalForm
|
||||
title="编辑角色"
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
initialValues={currentRow ?? undefined}
|
||||
onFinish={async (values) => {
|
||||
if (!currentRow) return false;
|
||||
const res = await updateRole(currentRow.roleId, values);
|
||||
if (res?.code === 0) { message.success('更新成功'); actionRef.current?.reload(); return true; }
|
||||
message.error(res?.msg || '更新失败'); return false;
|
||||
}}>
|
||||
<ProFormText name="roleName" label="角色名称" />
|
||||
<ProFormText name="roleKey" label="角色标识" />
|
||||
<ProFormTextArea name="roleDesc" label="描述" />
|
||||
<ProFormDigit name="sortOrder" label="排序" />
|
||||
message.error(res?.msg || '更新失败');
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{formFields}
|
||||
</ModalForm>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,31 +1,36 @@
|
||||
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 { useRef, useState } from 'react';
|
||||
import { listUsers, createUser, updateUser, deleteUser, updateUserStatus, listRoles } from '@/services/api';
|
||||
import type { User } from '@/types/api';
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [currentRow, setCurrentRow] = useState<any>(null);
|
||||
const [roles, setRoles] = useState<any[]>([]);
|
||||
const [currentRow, setCurrentRow] = useState<User | null>(null);
|
||||
const [roleOptions, setRoleOptions] = useState<{ label: string; value: string }[]>([]);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
const res = await listRoles({ page: 1, pageSize: 100 });
|
||||
if (res?.code === 0) {
|
||||
setRoles(res.data?.list?.map((r: any) => ({ label: r.roleName, value: r.roleId })) || []);
|
||||
setRoleOptions(
|
||||
res.data?.list?.map((r) => ({ label: r.roleName, value: r.roleId })) || [],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ProColumns[] = [
|
||||
const columns: ProColumns<User>[] = [
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
{ title: '真实姓名', dataIndex: 'realName' },
|
||||
{ title: '手机号', dataIndex: 'phone', search: false },
|
||||
{ title: '邮箱', dataIndex: 'email', search: false },
|
||||
{ title: '角色', dataIndex: 'roleName', search: false },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', search: false,
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
search: false,
|
||||
render: (_, record) => (
|
||||
<Switch
|
||||
checked={record.status === 1}
|
||||
@ -38,14 +43,19 @@ const UsersPage: React.FC = () => {
|
||||
},
|
||||
{ title: '最后登录', dataIndex: 'lastLoginTime', valueType: 'dateTime', search: false },
|
||||
{
|
||||
title: '操作', valueType: 'option',
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
render: (_, record) => [
|
||||
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); fetchRoles(); }}>编辑</a>,
|
||||
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
|
||||
<Popconfirm
|
||||
key="del"
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
const res = await deleteUser(record.userId);
|
||||
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
|
||||
}}>
|
||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||
}}
|
||||
>
|
||||
<a style={{ color: 'var(--ant-color-error)' }}>删除</a>
|
||||
</Popconfirm>,
|
||||
],
|
||||
},
|
||||
@ -53,13 +63,18 @@ const UsersPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProTable
|
||||
<ProTable<User>
|
||||
headerTitle="用户管理"
|
||||
actionRef={actionRef}
|
||||
rowKey="userId"
|
||||
columns={columns}
|
||||
request={async (params) => {
|
||||
const res = await listUsers({ page: params.current, pageSize: params.pageSize, username: params.username, realName: params.realName });
|
||||
const res = await listUsers({
|
||||
page: params.current,
|
||||
pageSize: params.pageSize,
|
||||
username: params.username,
|
||||
realName: params.realName,
|
||||
});
|
||||
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
@ -75,11 +90,7 @@ const UsersPage: React.FC = () => {
|
||||
onOpenChange={setCreateOpen}
|
||||
onFinish={async (values) => {
|
||||
const res = await createUser(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;
|
||||
}}
|
||||
@ -89,21 +100,18 @@ const UsersPage: React.FC = () => {
|
||||
<ProFormText name="realName" label="真实姓名" rules={[{ required: true }]} />
|
||||
<ProFormText name="phone" 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
|
||||
title="编辑用户"
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
initialValues={currentRow}
|
||||
initialValues={currentRow ?? undefined}
|
||||
onFinish={async (values) => {
|
||||
if (!currentRow) return false;
|
||||
const res = await updateUser(currentRow.userId, 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;
|
||||
}}
|
||||
@ -111,7 +119,7 @@ const UsersPage: React.FC = () => {
|
||||
<ProFormText name="realName" label="真实姓名" />
|
||||
<ProFormText name="phone" label="手机号" />
|
||||
<ProFormText name="email" label="邮箱" />
|
||||
<ProFormSelect name="roleId" label="角色" options={roles} />
|
||||
<ProFormSelect name="roleId" label="角色" options={roleOptions} />
|
||||
</ModalForm>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,177 +1,181 @@
|
||||
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 }) {
|
||||
return request('/api/v1/auth/login', { method: 'POST', data });
|
||||
}
|
||||
export const login = (data: LoginParams) =>
|
||||
request<ApiResponse<LoginResult>>('/api/v1/auth/login', { method: 'POST', data });
|
||||
|
||||
export async function logout() {
|
||||
return request('/api/v1/auth/logout', { method: 'POST' });
|
||||
}
|
||||
export const logout = () =>
|
||||
request<ApiResponse>('/api/v1/auth/logout', { method: 'POST' });
|
||||
|
||||
export async function getUserInfo() {
|
||||
return request('/api/v1/auth/info', { method: 'GET' });
|
||||
}
|
||||
export const getUserInfo = () =>
|
||||
request<ApiResponse<CurrentUser>>('/api/v1/auth/info', { method: 'GET' });
|
||||
|
||||
export async function changePassword(data: { oldPassword: string; newPassword: string }) {
|
||||
return request('/api/v1/auth/password', { method: 'PUT', data });
|
||||
}
|
||||
export const changePassword = (data: { oldPassword: string; newPassword: string }) =>
|
||||
request<ApiResponse>('/api/v1/auth/password', { method: 'PUT', data });
|
||||
|
||||
// ==================== Users ====================
|
||||
// ── Users ─────────────────────────────────────────
|
||||
|
||||
export async function listUsers(params: any) {
|
||||
return request('/api/v1/system/users', { method: 'GET', params });
|
||||
}
|
||||
export const listUsers = (params: PaginationParams & { username?: string; realName?: string }) =>
|
||||
request<ApiResponse<PaginatedData<User>>>('/api/v1/system/users', { method: 'GET', params });
|
||||
|
||||
export async function getUser(id: string) {
|
||||
return request(`/api/v1/system/users/${id}`, { method: 'GET' });
|
||||
}
|
||||
export const getUser = (id: string) =>
|
||||
request<ApiResponse<User>>(`/api/v1/system/users/${id}`, { method: 'GET' });
|
||||
|
||||
export async function createUser(data: any) {
|
||||
return request('/api/v1/system/users', { method: 'POST', data });
|
||||
}
|
||||
export const createUser = (data: Partial<User> & { password?: string }) =>
|
||||
request<ApiResponse>('/api/v1/system/users', { method: 'POST', data });
|
||||
|
||||
export async function updateUser(id: string, data: any) {
|
||||
return request(`/api/v1/system/users/${id}`, { method: 'PUT', data });
|
||||
}
|
||||
export const updateUser = (id: string, data: Partial<User>) =>
|
||||
request<ApiResponse>(`/api/v1/system/users/${id}`, { method: 'PUT', data });
|
||||
|
||||
export async function deleteUser(id: string) {
|
||||
return request(`/api/v1/system/users/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
export const deleteUser = (id: string) =>
|
||||
request<ApiResponse>(`/api/v1/system/users/${id}`, { method: 'DELETE' });
|
||||
|
||||
export async function updateUserStatus(id: string, status: number) {
|
||||
return request(`/api/v1/system/users/${id}/status`, { method: 'PUT', data: { status } });
|
||||
}
|
||||
export const updateUserStatus = (id: string, status: number) =>
|
||||
request<ApiResponse>(`/api/v1/system/users/${id}/status`, { method: 'PUT', data: { status } });
|
||||
|
||||
// ==================== Roles ====================
|
||||
// ── Roles ─────────────────────────────────────────
|
||||
|
||||
export async function listRoles(params?: any) {
|
||||
return request('/api/v1/system/roles', { method: 'GET', params });
|
||||
}
|
||||
export const listRoles = (params?: PaginationParams & { roleName?: string }) =>
|
||||
request<ApiResponse<PaginatedData<Role>>>('/api/v1/system/roles', { method: 'GET', params });
|
||||
|
||||
export async function createRole(data: any) {
|
||||
return request('/api/v1/system/roles', { method: 'POST', data });
|
||||
}
|
||||
export const createRole = (data: Partial<Role>) =>
|
||||
request<ApiResponse>('/api/v1/system/roles', { method: 'POST', data });
|
||||
|
||||
export async function updateRole(id: string, data: any) {
|
||||
return request(`/api/v1/system/roles/${id}`, { method: 'PUT', data });
|
||||
}
|
||||
export const updateRole = (id: string, data: Partial<Role>) =>
|
||||
request<ApiResponse>(`/api/v1/system/roles/${id}`, { method: 'PUT', data });
|
||||
|
||||
export async function deleteRole(id: string) {
|
||||
return request(`/api/v1/system/roles/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
export const deleteRole = (id: string) =>
|
||||
request<ApiResponse>(`/api/v1/system/roles/${id}`, { method: 'DELETE' });
|
||||
|
||||
export async function setRolePermissions(id: string, menuIds: string[]) {
|
||||
return request(`/api/v1/system/roles/${id}/permissions`, { method: 'PUT', data: { menuIds } });
|
||||
}
|
||||
export const setRolePermissions = (id: string, menuIds: string[]) =>
|
||||
request<ApiResponse>(`/api/v1/system/roles/${id}/permissions`, { method: 'PUT', data: { menuIds } });
|
||||
|
||||
// ==================== Menus ====================
|
||||
// ── Menus ─────────────────────────────────────────
|
||||
|
||||
export async function listMenus() {
|
||||
return request('/api/v1/system/menus', { method: 'GET' });
|
||||
}
|
||||
export const listMenus = () =>
|
||||
request<ApiResponse<PaginatedData<Menu>>>('/api/v1/system/menus', { method: 'GET' });
|
||||
|
||||
export async function createMenu(data: any) {
|
||||
return request('/api/v1/system/menus', { method: 'POST', data });
|
||||
}
|
||||
export const createMenu = (data: Partial<Menu>) =>
|
||||
request<ApiResponse>('/api/v1/system/menus', { method: 'POST', data });
|
||||
|
||||
export async function updateMenu(id: string, data: any) {
|
||||
return request(`/api/v1/system/menus/${id}`, { method: 'PUT', data });
|
||||
}
|
||||
export const updateMenu = (id: string, data: Partial<Menu>) =>
|
||||
request<ApiResponse>(`/api/v1/system/menus/${id}`, { method: 'PUT', data });
|
||||
|
||||
export async function deleteMenu(id: string) {
|
||||
return request(`/api/v1/system/menus/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
export const deleteMenu = (id: string) =>
|
||||
request<ApiResponse>(`/api/v1/system/menus/${id}`, { method: 'DELETE' });
|
||||
|
||||
// ==================== Logs ====================
|
||||
// ── Logs ──────────────────────────────────────────
|
||||
|
||||
export async function listLogs(params: any) {
|
||||
return request('/api/v1/system/logs', { method: 'GET', params });
|
||||
}
|
||||
export const listLogs = (params: PaginationParams & { username?: string; module?: string; operation?: string }) =>
|
||||
request<ApiResponse<PaginatedData<OperationLog>>>('/api/v1/system/logs', { method: 'GET', params });
|
||||
|
||||
// ==================== Configs ====================
|
||||
// ── Configs ───────────────────────────────────────
|
||||
|
||||
export async function listConfigs() {
|
||||
return request('/api/v1/system/configs', { method: 'GET' });
|
||||
}
|
||||
export const listConfigs = () =>
|
||||
request<ApiResponse<PaginatedData<SystemConfig>>>('/api/v1/system/configs', { method: 'GET' });
|
||||
|
||||
export async function updateConfig(key: string, value: string) {
|
||||
return request(`/api/v1/system/configs/${key}`, { method: 'PUT', data: { configValue: value } });
|
||||
}
|
||||
export const updateConfig = (key: string, value: string) =>
|
||||
request<ApiResponse>(`/api/v1/system/configs/${key}`, { method: 'PUT', data: { configValue: value } });
|
||||
|
||||
// ==================== Products ====================
|
||||
// ── Products ──────────────────────────────────────
|
||||
|
||||
export async function listProducts(params: any) {
|
||||
return request('/api/v1/inventory/products', { method: 'GET', params });
|
||||
}
|
||||
export const listProducts = (params: PaginationParams & { productName?: string; spec?: string; color?: string; location?: string }) =>
|
||||
request<ApiResponse<PaginatedData<Product>>>('/api/v1/inventory/products', { method: 'GET', params });
|
||||
|
||||
export async function getProduct(id: string) {
|
||||
return request(`/api/v1/inventory/products/${id}`, { method: 'GET' });
|
||||
}
|
||||
export const getProduct = (id: string) =>
|
||||
request<ApiResponse<Product>>(`/api/v1/inventory/products/${id}`, { method: 'GET' });
|
||||
|
||||
export async function createProduct(data: any) {
|
||||
return request('/api/v1/inventory/products', { method: 'POST', data });
|
||||
}
|
||||
export const createProduct = (data: Partial<Product>) =>
|
||||
request<ApiResponse>('/api/v1/inventory/products', { method: 'POST', data });
|
||||
|
||||
export async function updateProduct(id: string, data: any) {
|
||||
return request(`/api/v1/inventory/products/${id}`, { method: 'PUT', data });
|
||||
}
|
||||
export const updateProduct = (id: string, data: Partial<Product>) =>
|
||||
request<ApiResponse>(`/api/v1/inventory/products/${id}`, { method: 'PUT', data });
|
||||
|
||||
export async function deleteProduct(id: string) {
|
||||
return request(`/api/v1/inventory/products/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
export const deleteProduct = (id: string) =>
|
||||
request<ApiResponse>(`/api/v1/inventory/products/${id}`, { method: 'DELETE' });
|
||||
|
||||
export async function exportProducts() {
|
||||
return request('/api/v1/inventory/products/export', { method: 'GET', responseType: 'blob' });
|
||||
}
|
||||
export const exportProducts = () =>
|
||||
request('/api/v1/inventory/products/export', { method: 'GET', responseType: 'blob' });
|
||||
|
||||
// ==================== Stocks ====================
|
||||
// ── Stocks ────────────────────────────────────────
|
||||
|
||||
export async function getStockSummary() {
|
||||
return request('/api/v1/inventory/stocks/summary', { method: 'GET' });
|
||||
}
|
||||
export const getStockSummary = () =>
|
||||
request<ApiResponse<StockSummary>>('/api/v1/inventory/stocks/summary', { method: 'GET' });
|
||||
|
||||
export async function getStockByColor() {
|
||||
return request('/api/v1/inventory/stocks/by-color', { method: 'GET' });
|
||||
}
|
||||
export const getStockByColor = () =>
|
||||
request<ApiResponse<PaginatedData<StockGroup>>>('/api/v1/inventory/stocks/by-color', { method: 'GET' });
|
||||
|
||||
export async function getStockByLocation() {
|
||||
return request('/api/v1/inventory/stocks/by-location', { method: 'GET' });
|
||||
}
|
||||
export const getStockByLocation = () =>
|
||||
request<ApiResponse<PaginatedData<StockGroup>>>('/api/v1/inventory/stocks/by-location', { method: 'GET' });
|
||||
|
||||
// ==================== Stock Checks ====================
|
||||
// ── Stock Checks ──────────────────────────────────
|
||||
|
||||
export async function listStockChecks(params: any) {
|
||||
return request('/api/v1/inventory/checks', { method: 'GET', params });
|
||||
}
|
||||
export const listStockChecks = (params: PaginationParams) =>
|
||||
request<ApiResponse<PaginatedData<StockCheck>>>('/api/v1/inventory/checks', { method: 'GET', params });
|
||||
|
||||
export async function getStockCheck(id: string) {
|
||||
return request(`/api/v1/inventory/checks/${id}`, { method: 'GET' });
|
||||
}
|
||||
export const getStockCheck = (id: string) =>
|
||||
request<ApiResponse<StockCheck>>(`/api/v1/inventory/checks/${id}`, { method: 'GET' });
|
||||
|
||||
export async function createStockCheck(data: any) {
|
||||
return request('/api/v1/inventory/checks', { method: 'POST', data });
|
||||
}
|
||||
export const createStockCheck = (data: Partial<StockCheck>) =>
|
||||
request<ApiResponse>('/api/v1/inventory/checks', { method: 'POST', data });
|
||||
|
||||
export async function confirmStockCheck(id: string) {
|
||||
return request(`/api/v1/inventory/checks/${id}/confirm`, { method: 'POST' });
|
||||
}
|
||||
export const confirmStockCheck = (id: string) =>
|
||||
request<ApiResponse>(`/api/v1/inventory/checks/${id}/confirm`, { method: 'POST' });
|
||||
|
||||
// ==================== Stock Adjusts ====================
|
||||
// ── Stock Adjusts ─────────────────────────────────
|
||||
|
||||
export async function listStockAdjusts(params: any) {
|
||||
return request('/api/v1/inventory/adjusts', { method: 'GET', params });
|
||||
}
|
||||
export const listStockAdjusts = (params: PaginationParams) =>
|
||||
request<ApiResponse<PaginatedData<StockAdjust>>>('/api/v1/inventory/adjusts', { method: 'GET', params });
|
||||
|
||||
export async function getStockAdjust(id: string) {
|
||||
return request(`/api/v1/inventory/adjusts/${id}`, { method: 'GET' });
|
||||
}
|
||||
export const getStockAdjust = (id: string) =>
|
||||
request<ApiResponse<StockAdjust>>(`/api/v1/inventory/adjusts/${id}`, { method: 'GET' });
|
||||
|
||||
export async function createStockAdjust(data: any) {
|
||||
return request('/api/v1/inventory/adjusts', { method: 'POST', data });
|
||||
}
|
||||
export const createStockAdjust = (data: Partial<StockAdjust>) =>
|
||||
request<ApiResponse>('/api/v1/inventory/adjusts', { method: 'POST', data });
|
||||
|
||||
export async function approveStockAdjust(id: string, action: number) {
|
||||
return request(`/api/v1/inventory/adjusts/${id}/approve`, { method: 'POST', data: { action } });
|
||||
}
|
||||
export const approveStockAdjust = (id: string, action: number) =>
|
||||
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
157
src/types/api.ts
Normal 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
82
src/types/theme.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user