diff --git a/.umirc.ts b/.umirc.ts index 6eb6e36..d351dc4 100644 --- a/.umirc.ts +++ b/.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' }, ], }, ], diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index 0d35d34..0000000 --- a/src/app.ts +++ /dev/null @@ -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; - }, - }, -}; diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..e2b71a4 --- /dev/null +++ b/src/app.tsx @@ -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 { + 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: () => ( +
+

+ 木羽キハネ + 清風セイフウ +

+
+ ), + 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, +}; diff --git a/src/global.css b/src/global.css new file mode 100644 index 0000000..3327684 --- /dev/null +++ b/src/global.css @@ -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; + } +} diff --git a/src/pages/CRM/Graph/index.tsx b/src/pages/CRM/Graph/index.tsx new file mode 100644 index 0000000..9411290 --- /dev/null +++ b/src/pages/CRM/Graph/index.tsx @@ -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 = { SUPPLY: 'SUPPLY', OEM: 'OEM', PURCHASE: 'PURCHASE' }; +const relColor: Record = { SUPPLY: T.green, OEM: T.amber, PURCHASE: T.cyan }; + +const GraphPage: React.FC = () => { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const overlayRef = useRef(null); + const [loading, setLoading] = useState(true); + const nodesRef = useRef([]); + const edgesRef = useRef([]); + const animRef = useRef(0); + const dragRef = useRef<{ node: GNode | null; ox: number; oy: number }>({ node: null, ox: 0, oy: 0 }); + const hoverRef = useRef(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(); + 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 ( +
+

$ loading supply-chain-graph...

+

+
+ ); + } + + if (nodesRef.current.length === 0) { + return ( +
+

$ graph --status

+

[ERR] No relationship data found.

+
+ ); + } + + return ( +
+ {/* Header bar */} +
+ + +--- SUPPLY CHAIN GRAPH ---+ + + + NODES: {info.nodes} | EDGES: {info.edges} | MODE: FORCE-DIRECTED + +
+ + {/* Canvas area */} +
+ + +
+ + {/* Tooltip */} + {hoverInfo && ( +
+          {hoverInfo.lines.join('\n')}
+        
+ )} +
+ ); +}; + +export default GraphPage; diff --git a/src/pages/CRM/Relations/index.tsx b/src/pages/CRM/Relations/index.tsx new file mode 100644 index 0000000..fbf5001 --- /dev/null +++ b/src/pages/CRM/Relations/index.tsx @@ -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 = { + SUPPLY: '供货', + PURCHASE: '采购', + OEM: '代工', +}; + +const RelationsPage: React.FC = () => { + const [loading, setLoading] = useState(false); + const [upstream, setUpstream] = useState([]); + const [downstream, setDownstream] = useState([]); + const [history, setHistory] = useState([]); + 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 ? 生效 : 失效), + }, + { 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 ? 生效 : 失效), + }, + { 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 ( + + +
{ + 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 || '关系创建失败'); + }} + > + + + + + } + placeholder="用户名" + style={styles.input} + /> + + + + } + placeholder="密码" + style={styles.input} + /> + + + + } + placeholder="租户ID(必填)" + style={styles.input} + /> + + + + + +
+ +

+ Warehouse Management System +

+ ); }; +const styles: Record = { + 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; diff --git a/src/pages/System/Configs/index.tsx b/src/pages/System/Configs/index.tsx index 41ff288..0b46e06 100644 --- a/src/pages/System/Configs/index.tsx +++ b/src/pages/System/Configs/index.tsx @@ -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([]); + const [data, setData] = useState([]); 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 = [ { 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 ? 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 - ? <> - handleSave(record.configKey)} style={{ marginRight: 8 }}>保存 - setEditingKey('')}>取消 - - : { setEditingKey(record.configKey); setEditValue(record.configValue); }}>编辑, + title: '操作', + key: 'action', + render: (_, record) => + editingKey === record.configKey ? ( + + handleSave(record.configKey)}>保存 + setEditingKey('')}>取消 + + ) : ( + { setEditingKey(record.configKey); setEditValue(record.configValue); }}>编辑 + ), }, ]; return ( - - + + + columns={columns} + dataSource={data} + rowKey="configKey" + loading={loading} + pagination={false} + /> ); diff --git a/src/pages/System/Logs/index.tsx b/src/pages/System/Logs/index.tsx index b605bfe..87102ee 100644 --- a/src/pages/System/Logs/index.tsx +++ b/src/pages/System/Logs/index.tsx @@ -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[] = [ { 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) => {r.status === 1 ? '成功' : '失败'}, }, { title: '操作时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false }, ]; const LogsPage: React.FC = () => ( - 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 }; }} diff --git a/src/pages/System/Menus/index.tsx b/src/pages/System/Menus/index.tsx index 10b0456..385b166 100644 --- a/src/pages/System/Menus/index.tsx +++ b/src/pages/System/Menus/index.tsx @@ -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 = { +const MENU_TYPE_MAP: Record = { 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 {t.text}; - }, - }, - { title: '排序', dataIndex: 'sortOrder', key: 'sortOrder' }, - { - title: '操作', key: 'action', - render: (_: any, record: any) => [ - 编辑, - { - const res = await deleteMenu(record.menuId); - if (res?.code === 0) message.success('删除成功'); - }}> - 删除 - , - ], - }, -]; - const MenusPage: React.FC = () => { - const [data, setData] = useState([]); + const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const fetchData = async () => { @@ -49,17 +25,52 @@ const MenusPage: React.FC = () => { useEffect(() => { fetchData(); }, []); + const columns: ColumnsType = [ + { 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 {t.text}; + }, + }, + { title: '排序', dataIndex: 'sortOrder', key: 'sortOrder' }, + { + title: '操作', + key: 'action', + render: (_, record) => [ + 编辑, + { + const res = await deleteMenu(record.menuId); + if (res?.code === 0) { message.success('删除成功'); fetchData(); } + }} + > + 删除 + , + ], + }, + ]; + return ( - -
+ + + + columns={columns} + dataSource={data} + rowKey="menuId" + loading={loading} + childrenColumnName="children" + pagination={false} + /> + ); }; diff --git a/src/pages/System/Roles/index.tsx b/src/pages/System/Roles/index.tsx index 18aa31c..e9e32c5 100644 --- a/src/pages/System/Roles/index.tsx +++ b/src/pages/System/Roles/index.tsx @@ -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(); const [createOpen, setCreateOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); - const [currentRow, setCurrentRow] = useState(null); + const [currentRow, setCurrentRow] = useState(null); - const columns: ProColumns[] = [ + const columns: ProColumns[] = [ { title: '角色名称', dataIndex: 'roleName' }, { title: '角色标识', dataIndex: 'roleKey', search: false }, { title: '描述', dataIndex: 'roleDesc', search: false, ellipsis: true }, { title: '排序', dataIndex: 'sortOrder', search: false }, { title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false }, { - title: '操作', valueType: 'option', + title: '操作', + valueType: 'option', render: (_, record) => [ { setCurrentRow(record); setEditOpen(true); }}>编辑, - { - const res = await deleteRole(record.roleId); - if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); } - }}> - 删除 + { + const res = await deleteRole(record.roleId); + if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); } + }} + > + 删除 , ], }, ]; + const formFields = ( + <> + + + + + + ); + return ( <> - headerTitle="角色管理" actionRef={actionRef} rowKey="roleId" @@ -45,27 +60,33 @@ const RolesPage: React.FC = () => { , ]} /> - { const res = await createRole(values); if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; } - message.error(res?.msg || '创建失败'); return false; - }}> - - - - + message.error(res?.msg || '创建失败'); + return false; + }} + > + {formFields} - { + 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; - }}> - - - - + message.error(res?.msg || '更新失败'); + return false; + }} + > + {formFields} ); diff --git a/src/pages/System/Users/index.tsx b/src/pages/System/Users/index.tsx index 830e165..1deb5a3 100644 --- a/src/pages/System/Users/index.tsx +++ b/src/pages/System/Users/index.tsx @@ -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(); const [createOpen, setCreateOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); - const [currentRow, setCurrentRow] = useState(null); - const [roles, setRoles] = useState([]); + const [currentRow, setCurrentRow] = useState(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[] = [ { 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) => ( { }, { title: '最后登录', dataIndex: 'lastLoginTime', valueType: 'dateTime', search: false }, { - title: '操作', valueType: 'option', + title: '操作', + valueType: 'option', render: (_, record) => [ { setCurrentRow(record); setEditOpen(true); fetchRoles(); }}>编辑, - { - const res = await deleteUser(record.userId); - if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); } - }}> - 删除 + { + const res = await deleteUser(record.userId); + if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); } + }} + > + 删除 , ], }, @@ -53,13 +63,18 @@ const UsersPage: React.FC = () => { return ( <> - 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 = () => { - + { + 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 = () => { - + ); diff --git a/src/services/api.ts b/src/services/api.ts index 6e45024..1c4b280 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -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>('/api/v1/auth/login', { method: 'POST', data }); -export async function logout() { - return request('/api/v1/auth/logout', { method: 'POST' }); -} +export const logout = () => + request('/api/v1/auth/logout', { method: 'POST' }); -export async function getUserInfo() { - return request('/api/v1/auth/info', { method: 'GET' }); -} +export const getUserInfo = () => + request>('/api/v1/auth/info', { method: 'GET' }); -export async function changePassword(data: { oldPassword: string; newPassword: string }) { - return request('/api/v1/auth/password', { method: 'PUT', data }); -} +export const changePassword = (data: { oldPassword: string; newPassword: string }) => + request('/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>>('/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>(`/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 & { password?: string }) => + request('/api/v1/system/users', { method: 'POST', data }); -export async function updateUser(id: string, data: any) { - return request(`/api/v1/system/users/${id}`, { method: 'PUT', data }); -} +export const updateUser = (id: string, data: Partial) => + request(`/api/v1/system/users/${id}`, { method: 'PUT', data }); -export async function deleteUser(id: string) { - return request(`/api/v1/system/users/${id}`, { method: 'DELETE' }); -} +export const deleteUser = (id: string) => + request(`/api/v1/system/users/${id}`, { method: 'DELETE' }); -export async function updateUserStatus(id: string, status: number) { - return request(`/api/v1/system/users/${id}/status`, { method: 'PUT', data: { status } }); -} +export const updateUserStatus = (id: string, status: number) => + request(`/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>>('/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) => + request('/api/v1/system/roles', { method: 'POST', data }); -export async function updateRole(id: string, data: any) { - return request(`/api/v1/system/roles/${id}`, { method: 'PUT', data }); -} +export const updateRole = (id: string, data: Partial) => + request(`/api/v1/system/roles/${id}`, { method: 'PUT', data }); -export async function deleteRole(id: string) { - return request(`/api/v1/system/roles/${id}`, { method: 'DELETE' }); -} +export const deleteRole = (id: string) => + request(`/api/v1/system/roles/${id}`, { method: 'DELETE' }); -export async function setRolePermissions(id: string, menuIds: string[]) { - return request(`/api/v1/system/roles/${id}/permissions`, { method: 'PUT', data: { menuIds } }); -} +export const setRolePermissions = (id: string, menuIds: string[]) => + request(`/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>>('/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) => + request('/api/v1/system/menus', { method: 'POST', data }); -export async function updateMenu(id: string, data: any) { - return request(`/api/v1/system/menus/${id}`, { method: 'PUT', data }); -} +export const updateMenu = (id: string, data: Partial) => + request(`/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(`/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>>('/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>>('/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(`/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>>('/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>(`/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) => + request('/api/v1/inventory/products', { method: 'POST', data }); -export async function updateProduct(id: string, data: any) { - return request(`/api/v1/inventory/products/${id}`, { method: 'PUT', data }); -} +export const updateProduct = (id: string, data: Partial) => + request(`/api/v1/inventory/products/${id}`, { method: 'PUT', data }); -export async function deleteProduct(id: string) { - return request(`/api/v1/inventory/products/${id}`, { method: 'DELETE' }); -} +export const deleteProduct = (id: string) => + request(`/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>('/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>>('/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>>('/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>>('/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>(`/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) => + request('/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(`/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>>('/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>(`/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) => + request('/api/v1/inventory/adjusts', { method: 'POST', data }); -export async function approveStockAdjust(id: string, action: number) { - return request(`/api/v1/inventory/adjusts/${id}/approve`, { method: 'POST', data: { action } }); -} +export const approveStockAdjust = (id: string, action: number) => + request(`/api/v1/inventory/adjusts/${id}/approve`, { method: 'POST', data: { action } }); + +// ── CRM Relations ───────────────────────────────── + +export const listUpstream = (depth = 1) => + request>>('/api/v1/crm/graph/upstream', { method: 'GET', params: { depth } }); + +export const listDownstream = (depth = 1) => + request>>('/api/v1/crm/graph/downstream', { method: 'GET', params: { depth } }); + +export const createRelation = (data: { + fromTenantId: string; + toTenantId: string; + relationType: string; + validFrom?: string; + validTo?: string; +}) => + request('/api/v1/crm/relationships', { method: 'POST', data }); + +export const updateRelation = (id: string, data: { status: number; validFrom?: string; validTo?: string }) => + request(`/api/v1/crm/relationships/${id}`, { method: 'PUT', data }); + +export const listRelationHistory = (params?: PaginationParams) => + request>>('/api/v1/crm/relationships/history', { method: 'GET', params }); diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..8aa3c1a --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,157 @@ +export interface ApiResponse { + code: number; + msg?: string; + data: T; +} + +export interface PaginatedData { + 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; +} diff --git a/src/types/theme.ts b/src/types/theme.ts new file mode 100644 index 0000000..67996f0 --- /dev/null +++ b/src/types/theme.ts @@ -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, + }, + }, +};