Merge pull request 'feat: 多租户 CRM Portal + 供应商客户视图 + Terminal 风格关系图' (#1) from feat/multitenant-crm into main
Reviewed-on: https://forgejo.cheverjohn.me/Business/muyuqingfeng-portal/pulls/1
This commit is contained in:
commit
4aba6541f3
88
.umirc.ts
88
.umirc.ts
@ -7,9 +7,12 @@ export default defineConfig({
|
|||||||
initialState: {},
|
initialState: {},
|
||||||
request: {},
|
request: {},
|
||||||
layout: {
|
layout: {
|
||||||
title: 'muyuqingfeng仓储管理系统',
|
title: '木羽清风仓储系统',
|
||||||
locale: false,
|
locale: false,
|
||||||
},
|
},
|
||||||
|
headScripts: [
|
||||||
|
'https://fonts.googleapis.com/css2?family=Calistoga&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap',
|
||||||
|
],
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8888',
|
target: 'http://localhost:8888',
|
||||||
@ -32,41 +35,34 @@ export default defineConfig({
|
|||||||
component: './Dashboard',
|
component: './Dashboard',
|
||||||
icon: 'DashboardOutlined',
|
icon: 'DashboardOutlined',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '客户关系',
|
||||||
|
path: '/crm',
|
||||||
|
icon: 'NodeIndexOutlined',
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: '供应商与客户',
|
||||||
|
path: '/crm/relations',
|
||||||
|
component: './CRM/Relations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '关系全景图',
|
||||||
|
path: '/crm/graph',
|
||||||
|
component: './CRM/Graph',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: '库存管理',
|
name: '库存管理',
|
||||||
path: '/inventory',
|
path: '/inventory',
|
||||||
icon: 'DatabaseOutlined',
|
icon: 'DatabaseOutlined',
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{ name: '产品档案', path: '/inventory/products', component: './Inventory/Products' },
|
||||||
name: '产品档案',
|
{ name: '库存查询', path: '/inventory/stocks', component: './Inventory/Stocks' },
|
||||||
path: '/inventory/products',
|
{ name: '库存统计', path: '/inventory/stats', component: './Inventory/Stats' },
|
||||||
component: './Inventory/Products',
|
{ name: '库存盘点', path: '/inventory/checks', component: './Inventory/Checks' },
|
||||||
},
|
{ name: '库存调整', path: '/inventory/adjusts', component: './Inventory/Adjusts' },
|
||||||
{
|
{ name: 'Excel导入', path: '/inventory/import', component: './Inventory/Import' },
|
||||||
name: '库存查询',
|
|
||||||
path: '/inventory/stocks',
|
|
||||||
component: './Inventory/Stocks',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '库存统计',
|
|
||||||
path: '/inventory/stats',
|
|
||||||
component: './Inventory/Stats',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '库存盘点',
|
|
||||||
path: '/inventory/checks',
|
|
||||||
component: './Inventory/Checks',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '库存调整',
|
|
||||||
path: '/inventory/adjusts',
|
|
||||||
component: './Inventory/Adjusts',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Excel导入',
|
|
||||||
path: '/inventory/import',
|
|
||||||
component: './Inventory/Import',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -74,31 +70,11 @@ export default defineConfig({
|
|||||||
path: '/system',
|
path: '/system',
|
||||||
icon: 'SettingOutlined',
|
icon: 'SettingOutlined',
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{ name: '用户管理', path: '/system/users', component: './System/Users' },
|
||||||
name: '用户管理',
|
{ name: '角色管理', path: '/system/roles', component: './System/Roles' },
|
||||||
path: '/system/users',
|
{ name: '菜单管理', path: '/system/menus', component: './System/Menus' },
|
||||||
component: './System/Users',
|
{ name: '操作日志', path: '/system/logs', component: './System/Logs' },
|
||||||
},
|
{ name: '系统配置', path: '/system/configs', component: './System/Configs' },
|
||||||
{
|
|
||||||
name: '角色管理',
|
|
||||||
path: '/system/roles',
|
|
||||||
component: './System/Roles',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '菜单管理',
|
|
||||||
path: '/system/menus',
|
|
||||||
component: './System/Menus',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '操作日志',
|
|
||||||
path: '/system/logs',
|
|
||||||
component: './System/Logs',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '系统配置',
|
|
||||||
path: '/system/configs',
|
|
||||||
component: './System/Configs',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
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 React, { useEffect, useState } from 'react';
|
||||||
import { Col, Row } from 'antd';
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
import { useEffect, useState } from 'react';
|
import { Card, Col, Row, Spin } from 'antd';
|
||||||
|
import {
|
||||||
|
ShoppingOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
RiseOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
import { getStockSummary } from '@/services/api';
|
import { getStockSummary } from '@/services/api';
|
||||||
|
import type { StockSummary } from '@/types/api';
|
||||||
|
|
||||||
const { Statistic } = StatisticCard;
|
interface StatCardProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
suffix?: string;
|
||||||
|
prefix?: string;
|
||||||
|
gradient?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard: React.FC<StatCardProps> = ({ icon, title, value, suffix, prefix, gradient }) => (
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
border: gradient ? '2px solid transparent' : '1px solid #E2E8F0',
|
||||||
|
background: gradient
|
||||||
|
? 'linear-gradient(135deg, #0052FF, #4D7CFF)'
|
||||||
|
: '#FFFFFF',
|
||||||
|
boxShadow: gradient
|
||||||
|
? '0 8px 24px rgba(0,82,255,0.25)'
|
||||||
|
: '0 1px 3px rgba(0,0,0,0.06)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
cursor: 'default',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
hoverable
|
||||||
|
styles={{
|
||||||
|
body: { padding: '28px 24px' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: gradient
|
||||||
|
? 'rgba(255,255,255,0.2)'
|
||||||
|
: 'rgba(0,82,255,0.06)',
|
||||||
|
fontSize: 20,
|
||||||
|
color: gradient ? '#FFFFFF' : '#0052FF',
|
||||||
|
}}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: gradient ? 'rgba(255,255,255,0.8)' : '#64748B',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: '"Inter", system-ui, sans-serif',
|
||||||
|
color: gradient ? '#FFFFFF' : '#0F172A',
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}>
|
||||||
|
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}
|
||||||
|
{suffix && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginLeft: 4,
|
||||||
|
color: gradient ? 'rgba(255,255,255,0.7)' : '#94A3B8',
|
||||||
|
}}>
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
const DashboardPage: React.FC = () => {
|
const DashboardPage: React.FC = () => {
|
||||||
const [summary, setSummary] = useState<any>({});
|
const [summary, setSummary] = useState<StockSummary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getStockSummary().then((res) => {
|
getStockSummary()
|
||||||
if (res?.code === 0) {
|
.then((res) => {
|
||||||
setSummary(res.data);
|
if (res?.code === 0) setSummary(res.data);
|
||||||
}
|
})
|
||||||
});
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 120 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer
|
||||||
<Row gutter={[16, 16]}>
|
header={{
|
||||||
<Col span={4}>
|
title: (
|
||||||
<StatisticCard
|
<span style={{ fontFamily: '"Calistoga", Georgia, serif', fontSize: 24 }}>
|
||||||
statistic={{
|
数据总览
|
||||||
title: '产品总数',
|
</span>
|
||||||
value: summary.productCount || 0,
|
),
|
||||||
suffix: '种',
|
}}
|
||||||
}}
|
>
|
||||||
|
<Row gutter={[20, 20]}>
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<StatCard
|
||||||
|
icon={<ShoppingOutlined />}
|
||||||
|
title="产品总数"
|
||||||
|
value={summary?.productCount ?? 0}
|
||||||
|
suffix="种"
|
||||||
|
gradient
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={5}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<StatisticCard
|
<StatCard
|
||||||
statistic={{
|
icon={<InboxOutlined />}
|
||||||
title: '总匹数',
|
title="总匹数"
|
||||||
value: summary.totalPieces || 0,
|
value={summary?.totalPieces ?? 0}
|
||||||
suffix: '匹',
|
suffix="匹"
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={5}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<StatisticCard
|
<StatCard
|
||||||
statistic={{
|
icon={<DatabaseOutlined />}
|
||||||
title: '总盘数',
|
title="总盘数"
|
||||||
value: summary.totalRolls || 0,
|
value={summary?.totalRolls ?? 0}
|
||||||
suffix: '盘',
|
suffix="盘"
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={5}>
|
<Col xs={24} sm={12} lg={12}>
|
||||||
<StatisticCard
|
<StatCard
|
||||||
statistic={{
|
icon={<DollarOutlined />}
|
||||||
title: '库存总值(成本)',
|
title="库存总值(成本)"
|
||||||
value: summary.totalCostValue || '0.00',
|
value={summary?.totalCostValue ?? '0.00'}
|
||||||
prefix: '¥',
|
prefix="¥"
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={5}>
|
<Col xs={24} sm={12} lg={12}>
|
||||||
<StatisticCard
|
<StatCard
|
||||||
statistic={{
|
icon={<RiseOutlined />}
|
||||||
title: '库存总值(销售)',
|
title="库存总值(销售)"
|
||||||
value: summary.totalSalesValue || '0.00',
|
value={summary?.totalSalesValue ?? '0.00'}
|
||||||
prefix: '¥',
|
prefix="¥"
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* Quick navigation section */}
|
||||||
|
<div style={{ marginTop: 32 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
borderRadius: 24,
|
||||||
|
border: '1px solid rgba(0,82,255,0.2)',
|
||||||
|
background: 'rgba(0,82,255,0.04)',
|
||||||
|
padding: '8px 18px',
|
||||||
|
marginBottom: 20,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#0052FF',
|
||||||
|
}} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: '"JetBrains Mono", monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
color: '#0052FF',
|
||||||
|
}}>
|
||||||
|
快捷入口
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{[
|
||||||
|
{ title: '产品档案', desc: '管理产品信息', path: '/inventory/products', icon: <ShoppingOutlined /> },
|
||||||
|
{ title: '库存查询', desc: '实时库存数据', path: '/inventory/stocks', icon: <DatabaseOutlined /> },
|
||||||
|
{ title: '库存统计', desc: '分类统计分析', path: '/inventory/stats', icon: <RiseOutlined /> },
|
||||||
|
{ title: 'Excel导入', desc: '批量导入产品', path: '/inventory/import', icon: <InboxOutlined /> },
|
||||||
|
].map((item) => (
|
||||||
|
<Col xs={24} sm={12} lg={6} key={item.path}>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
hoverable
|
||||||
|
style={{
|
||||||
|
borderRadius: 14,
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: '20px 22px' } }}
|
||||||
|
onClick={() => window.location.assign(item.path)}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'linear-gradient(135deg, #0052FF, #4D7CFF)',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 18,
|
||||||
|
}}>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: '#0F172A' }}>{item.title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#94A3B8', marginTop: 2 }}>{item.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,22 +2,25 @@ import { PlusOutlined } from '@ant-design/icons';
|
|||||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
import { Button, Tag } from 'antd';
|
import { Button, Tag } from 'antd';
|
||||||
import { listStockAdjusts } from '@/services/api';
|
import { listStockAdjusts } from '@/services/api';
|
||||||
|
import type { StockAdjust } from '@/types/api';
|
||||||
|
|
||||||
const statusMap: Record<number, { text: string; color: string }> = {
|
const STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||||
0: { text: '待审核', color: 'warning' },
|
0: { text: '待审核', color: 'warning' },
|
||||||
1: { text: '已审核', color: 'success' },
|
1: { text: '已审核', color: 'success' },
|
||||||
2: { text: '已驳回', color: 'error' },
|
2: { text: '已驳回', color: 'error' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ProColumns[] = [
|
const columns: ProColumns<StockAdjust>[] = [
|
||||||
{ title: '调整单号', dataIndex: 'adjustNo' },
|
{ title: '调整单号', dataIndex: 'adjustNo' },
|
||||||
{ title: '调整日期', dataIndex: 'adjustDate', valueType: 'date', search: false },
|
{ title: '调整日期', dataIndex: 'adjustDate', valueType: 'date', search: false },
|
||||||
{ title: '调整原因', dataIndex: 'adjustReason', search: false },
|
{ title: '调整原因', dataIndex: 'adjustReason', search: false },
|
||||||
{ title: '操作人', dataIndex: 'operator', search: false },
|
{ title: '操作人', dataIndex: 'operator', search: false },
|
||||||
{
|
{
|
||||||
title: '状态', dataIndex: 'status', search: false,
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
search: false,
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
const s = statusMap[record.status] || statusMap[0];
|
const s = STATUS_MAP[record.status] ?? STATUS_MAP[0];
|
||||||
return <Tag color={s.color}>{s.text}</Tag>;
|
return <Tag color={s.color}>{s.text}</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -26,21 +29,14 @@ const columns: ProColumns[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const AdjustsPage: React.FC = () => (
|
const AdjustsPage: React.FC = () => (
|
||||||
<ProTable
|
<ProTable<StockAdjust>
|
||||||
headerTitle="库存调整"
|
headerTitle="库存调整"
|
||||||
rowKey="adjustId"
|
rowKey="adjustId"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
search={false}
|
search={false}
|
||||||
request={async (params) => {
|
request={async (params) => {
|
||||||
const res = await listStockAdjusts({
|
const res = await listStockAdjusts({ page: params.current, pageSize: params.pageSize });
|
||||||
page: params.current,
|
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||||
pageSize: params.pageSize,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
data: res?.data?.list || [],
|
|
||||||
total: res?.data?.total || 0,
|
|
||||||
success: res?.code === 0,
|
|
||||||
};
|
|
||||||
}}
|
}}
|
||||||
toolBarRender={() => [
|
toolBarRender={() => [
|
||||||
<Button key="add" type="primary" icon={<PlusOutlined />}>创建调整单</Button>,
|
<Button key="add" type="primary" icon={<PlusOutlined />}>创建调整单</Button>,
|
||||||
|
|||||||
@ -2,21 +2,24 @@ import { PlusOutlined } from '@ant-design/icons';
|
|||||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
import { Button, Tag } from 'antd';
|
import { Button, Tag } from 'antd';
|
||||||
import { listStockChecks } from '@/services/api';
|
import { listStockChecks } from '@/services/api';
|
||||||
|
import type { StockCheck } from '@/types/api';
|
||||||
|
|
||||||
const statusMap: Record<number, { text: string; color: string }> = {
|
const STATUS_MAP: Record<number, { text: string; color: string }> = {
|
||||||
0: { text: '草稿', color: 'default' },
|
0: { text: '草稿', color: 'default' },
|
||||||
1: { text: '进行中', color: 'processing' },
|
1: { text: '进行中', color: 'processing' },
|
||||||
2: { text: '已完成', color: 'success' },
|
2: { text: '已完成', color: 'success' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ProColumns[] = [
|
const columns: ProColumns<StockCheck>[] = [
|
||||||
{ title: '盘点单号', dataIndex: 'checkNo' },
|
{ title: '盘点单号', dataIndex: 'checkNo' },
|
||||||
{ title: '盘点日期', dataIndex: 'checkDate', valueType: 'date', search: false },
|
{ title: '盘点日期', dataIndex: 'checkDate', valueType: 'date', search: false },
|
||||||
{ title: '盘点人', dataIndex: 'checker', search: false },
|
{ title: '盘点人', dataIndex: 'checker', search: false },
|
||||||
{
|
{
|
||||||
title: '状态', dataIndex: 'status', search: false,
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
search: false,
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
const s = statusMap[record.status] || statusMap[0];
|
const s = STATUS_MAP[record.status] ?? STATUS_MAP[0];
|
||||||
return <Tag color={s.color}>{s.text}</Tag>;
|
return <Tag color={s.color}>{s.text}</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -25,21 +28,14 @@ const columns: ProColumns[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ChecksPage: React.FC = () => (
|
const ChecksPage: React.FC = () => (
|
||||||
<ProTable
|
<ProTable<StockCheck>
|
||||||
headerTitle="库存盘点"
|
headerTitle="库存盘点"
|
||||||
rowKey="checkId"
|
rowKey="checkId"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
search={false}
|
search={false}
|
||||||
request={async (params) => {
|
request={async (params) => {
|
||||||
const res = await listStockChecks({
|
const res = await listStockChecks({ page: params.current, pageSize: params.pageSize });
|
||||||
page: params.current,
|
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||||
pageSize: params.pageSize,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
data: res?.data?.list || [],
|
|
||||||
total: res?.data?.total || 0,
|
|
||||||
success: res?.code === 0,
|
|
||||||
};
|
|
||||||
}}
|
}}
|
||||||
toolBarRender={() => [
|
toolBarRender={() => [
|
||||||
<Button key="add" type="primary" icon={<PlusOutlined />}>创建盘点单</Button>,
|
<Button key="add" type="primary" icon={<PlusOutlined />}>创建盘点单</Button>,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
import { PageContainer } from '@ant-design/pro-components';
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
import { InboxOutlined, DownloadOutlined } from '@ant-design/icons';
|
import { InboxOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
import { Upload, Card, message, Button, Alert } from 'antd';
|
import { Upload, Card, message, Button, Alert, Space } from 'antd';
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
@ -9,39 +10,38 @@ const ImportPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Card title="Excel 批量导入">
|
<Card title="Excel 批量导入" bordered={false}>
|
||||||
<Alert
|
<Space direction="vertical" size={20} style={{ display: 'flex' }}>
|
||||||
message="导入说明"
|
<Alert
|
||||||
description="请按照模板格式准备 Excel 文件,必填字段:产品名称、库存数量。支持 .xlsx 和 .xls 格式。"
|
message="导入说明"
|
||||||
type="info"
|
description="请按照模板格式准备 Excel 文件,必填字段:产品名称、库存数量。支持 .xlsx 和 .xls 格式。"
|
||||||
showIcon
|
type="info"
|
||||||
style={{ marginBottom: 24 }}
|
showIcon
|
||||||
/>
|
/>
|
||||||
<Button icon={<DownloadOutlined />} style={{ marginBottom: 16 }}>
|
<Button icon={<DownloadOutlined />}>下载导入模板</Button>
|
||||||
下载导入模板
|
<Dragger
|
||||||
</Button>
|
name="file"
|
||||||
<Dragger
|
action="/api/v1/inventory/products/import"
|
||||||
name="file"
|
accept=".xlsx,.xls,.csv"
|
||||||
action="/api/v1/inventory/products/import"
|
headers={{ Authorization: `Bearer ${token}` }}
|
||||||
accept=".xlsx,.xls,.csv"
|
onChange={(info) => {
|
||||||
headers={{ Authorization: `Bearer ${token}` }}
|
if (info.file.status === 'done') {
|
||||||
onChange={(info) => {
|
const res = info.file.response;
|
||||||
if (info.file.status === 'done') {
|
if (res?.code === 0) {
|
||||||
const res = info.file.response;
|
message.success(`导入完成:成功 ${res.data.successCount} 条,失败 ${res.data.failCount} 条`);
|
||||||
if (res?.code === 0) {
|
} else {
|
||||||
message.success(`导入完成:成功 ${res.data.successCount} 条,失败 ${res.data.failCount} 条`);
|
message.error(res?.msg || '导入失败');
|
||||||
} else {
|
}
|
||||||
message.error(res?.msg || '导入失败');
|
} else if (info.file.status === 'error') {
|
||||||
|
message.error('文件上传失败');
|
||||||
}
|
}
|
||||||
} else if (info.file.status === 'error') {
|
}}
|
||||||
message.error('文件上传失败');
|
>
|
||||||
}
|
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||||
}}
|
<p className="ant-upload-text">点击或拖拽文件到此处上传</p>
|
||||||
>
|
<p className="ant-upload-hint">支持 .xlsx、.xls、.csv 格式</p>
|
||||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
</Dragger>
|
||||||
<p className="ant-upload-text">点击或拖拽文件到此处上传</p>
|
</Space>
|
||||||
<p className="ant-upload-hint">支持 .xlsx、.xls、.csv 格式</p>
|
|
||||||
</Dragger>
|
|
||||||
</Card>
|
</Card>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,16 +1,32 @@
|
|||||||
import { PlusOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
import { PlusOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormDigit, ProFormTextArea } from '@ant-design/pro-components';
|
import { ActionType, ModalForm, ProColumns, ProFormDigit, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components';
|
||||||
import { Button, message, Popconfirm } from 'antd';
|
import { Button, message, Popconfirm } from 'antd';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { listProducts, createProduct, updateProduct, deleteProduct } from '@/services/api';
|
import { listProducts, createProduct, updateProduct, deleteProduct } from '@/services/api';
|
||||||
|
import type { Product } from '@/types/api';
|
||||||
|
|
||||||
|
const productFormFields = (
|
||||||
|
<>
|
||||||
|
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
|
||||||
|
<ProFormText name="spec" label="规格型号" />
|
||||||
|
<ProFormText name="color" label="颜色" />
|
||||||
|
<ProFormDigit name="unitPieces" label="匹数" min={0} />
|
||||||
|
<ProFormDigit name="unitRolls" label="盘数" min={0} />
|
||||||
|
<ProFormText name="stockQuantity" label="库存数量" rules={[{ required: true }]} />
|
||||||
|
<ProFormText name="location" label="存放位置" />
|
||||||
|
<ProFormText name="costPrice" label="成本价" />
|
||||||
|
<ProFormText name="salesPrice" label="销售价" />
|
||||||
|
<ProFormTextArea name="remark" label="备注" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const ProductsPage: React.FC = () => {
|
const ProductsPage: React.FC = () => {
|
||||||
const actionRef = useRef<ActionType>();
|
const actionRef = useRef<ActionType>();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [currentRow, setCurrentRow] = useState<any>(null);
|
const [currentRow, setCurrentRow] = useState<Product | null>(null);
|
||||||
|
|
||||||
const columns: ProColumns[] = [
|
const columns: ProColumns<Product>[] = [
|
||||||
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
|
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
|
||||||
{ title: '规格型号', dataIndex: 'spec', ellipsis: true },
|
{ title: '规格型号', dataIndex: 'spec', ellipsis: true },
|
||||||
{ title: '颜色', dataIndex: 'color' },
|
{ title: '颜色', dataIndex: 'color' },
|
||||||
@ -22,69 +38,81 @@ const ProductsPage: React.FC = () => {
|
|||||||
{ title: '销售价', dataIndex: 'salesPrice', search: false, valueType: 'money' },
|
{ title: '销售价', dataIndex: 'salesPrice', search: false, valueType: 'money' },
|
||||||
{ title: '更新时间', dataIndex: 'updatedAt', valueType: 'dateTime', search: false },
|
{ title: '更新时间', dataIndex: 'updatedAt', valueType: 'dateTime', search: false },
|
||||||
{
|
{
|
||||||
title: '操作', valueType: 'option',
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
render: (_, record) => [
|
render: (_, record) => [
|
||||||
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); }}>编辑</a>,
|
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); }}>编辑</a>,
|
||||||
<Popconfirm key="delete" title="确认删除?" onConfirm={async () => {
|
<Popconfirm
|
||||||
const res = await deleteProduct(record.productId);
|
key="delete"
|
||||||
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
|
title="确认删除?"
|
||||||
}}>
|
onConfirm={async () => {
|
||||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
const res = await deleteProduct(record.productId);
|
||||||
|
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a style={{ color: 'var(--ant-color-error)' }}>删除</a>
|
||||||
</Popconfirm>,
|
</Popconfirm>,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const productFormFields = (
|
const normalizeValues = (values: Record<string, any>) => ({
|
||||||
<>
|
...values,
|
||||||
<ProFormText name="productName" label="产品名称" rules={[{ required: true }]} />
|
stockQuantity: String(values.stockQuantity || '0'),
|
||||||
<ProFormText name="spec" label="规格型号" />
|
costPrice: String(values.costPrice || '0'),
|
||||||
<ProFormText name="color" label="颜色" />
|
salesPrice: String(values.salesPrice || '0'),
|
||||||
<ProFormDigit name="unitPieces" label="匹数" min={0} />
|
});
|
||||||
<ProFormDigit name="unitRolls" label="盘数" min={0} />
|
|
||||||
<ProFormText name="stockQuantity" label="库存数量" rules={[{ required: true }]} />
|
|
||||||
<ProFormText name="location" label="存放位置" />
|
|
||||||
<ProFormText name="costPrice" label="成本价" />
|
|
||||||
<ProFormText name="salesPrice" label="销售价" />
|
|
||||||
<ProFormTextArea name="remark" label="备注" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProTable
|
<ProTable<Product>
|
||||||
headerTitle="产品档案"
|
headerTitle="产品档案"
|
||||||
actionRef={actionRef}
|
actionRef={actionRef}
|
||||||
rowKey="productId"
|
rowKey="productId"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
request={async (params) => {
|
request={async (params) => {
|
||||||
const res = await listProducts({
|
const res = await listProducts({
|
||||||
page: params.current, pageSize: params.pageSize,
|
page: params.current,
|
||||||
productName: params.productName, spec: params.spec,
|
pageSize: params.pageSize,
|
||||||
color: params.color, location: params.location,
|
productName: params.productName,
|
||||||
|
spec: params.spec,
|
||||||
|
color: params.color,
|
||||||
|
location: params.location,
|
||||||
});
|
});
|
||||||
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||||
}}
|
}}
|
||||||
toolBarRender={() => [
|
toolBarRender={() => [
|
||||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新增产品</Button>,
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新增产品</Button>,
|
||||||
<Button key="import" icon={<UploadOutlined />} onClick={() => window.location.href = '/inventory/import'}>Excel导入</Button>,
|
<Button key="import" icon={<UploadOutlined />} onClick={() => window.location.assign('/inventory/import')}>Excel导入</Button>,
|
||||||
<Button key="export" icon={<DownloadOutlined />}>Excel导出</Button>,
|
<Button key="export" icon={<DownloadOutlined />}>Excel导出</Button>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<ModalForm title="新增产品" open={createOpen} onOpenChange={setCreateOpen}
|
<ModalForm
|
||||||
|
title="新增产品"
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
onFinish={async (values) => {
|
onFinish={async (values) => {
|
||||||
const res = await createProduct({ ...values, stockQuantity: String(values.stockQuantity || '0'), costPrice: String(values.costPrice || '0'), salesPrice: String(values.salesPrice || '0') });
|
const res = await createProduct(normalizeValues(values));
|
||||||
if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
|
if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
|
||||||
message.error(res?.msg || '创建失败'); return false;
|
message.error(res?.msg || '创建失败');
|
||||||
}}>
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
{productFormFields}
|
{productFormFields}
|
||||||
</ModalForm>
|
</ModalForm>
|
||||||
<ModalForm title="编辑产品" open={editOpen} onOpenChange={setEditOpen} initialValues={currentRow}
|
<ModalForm
|
||||||
|
title="编辑产品"
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
initialValues={currentRow ?? undefined}
|
||||||
onFinish={async (values) => {
|
onFinish={async (values) => {
|
||||||
const res = await updateProduct(currentRow.productId, { ...values, stockQuantity: String(values.stockQuantity || '0'), costPrice: String(values.costPrice || '0'), salesPrice: String(values.salesPrice || '0') });
|
if (!currentRow) return false;
|
||||||
|
const res = await updateProduct(currentRow.productId, normalizeValues(values));
|
||||||
if (res?.code === 0) { message.success('更新成功'); actionRef.current?.reload(); return true; }
|
if (res?.code === 0) { message.success('更新成功'); actionRef.current?.reload(); return true; }
|
||||||
message.error(res?.msg || '更新失败'); return false;
|
message.error(res?.msg || '更新失败');
|
||||||
}}>
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
{productFormFields}
|
{productFormFields}
|
||||||
</ModalForm>
|
</ModalForm>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,56 +1,108 @@
|
|||||||
import { PageContainer, StatisticCard } from '@ant-design/pro-components';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Col, Row, Table } from 'antd';
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
import { useEffect, useState } from 'react';
|
import { Card, Col, Row, Table, Spin } from 'antd';
|
||||||
|
import {
|
||||||
|
ShoppingOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
RiseOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
import { getStockSummary, getStockByColor, getStockByLocation } from '@/services/api';
|
import { getStockSummary, getStockByColor, getStockByLocation } from '@/services/api';
|
||||||
|
import type { StockSummary, StockGroup } from '@/types/api';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
|
const groupColumns: ColumnsType<StockGroup> = [
|
||||||
|
{ title: '名称', dataIndex: 'name' },
|
||||||
|
{ title: '产品数', dataIndex: 'count' },
|
||||||
|
{ title: '库存量', dataIndex: 'quantity' },
|
||||||
|
];
|
||||||
|
|
||||||
const StatsPage: React.FC = () => {
|
const StatsPage: React.FC = () => {
|
||||||
const [summary, setSummary] = useState<any>({});
|
const [summary, setSummary] = useState<StockSummary | null>(null);
|
||||||
const [colorData, setColorData] = useState<any[]>([]);
|
const [colorData, setColorData] = useState<StockGroup[]>([]);
|
||||||
const [locationData, setLocationData] = useState<any[]>([]);
|
const [locationData, setLocationData] = useState<StockGroup[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getStockSummary().then((res) => res?.code === 0 && setSummary(res.data));
|
Promise.all([
|
||||||
getStockByColor().then((res) => res?.code === 0 && setColorData(res.data?.list || []));
|
getStockSummary(),
|
||||||
getStockByLocation().then((res) => res?.code === 0 && setLocationData(res.data?.list || []));
|
getStockByColor(),
|
||||||
|
getStockByLocation(),
|
||||||
|
]).then(([sumRes, colorRes, locRes]) => {
|
||||||
|
if (sumRes?.code === 0) setSummary(sumRes.data);
|
||||||
|
if (colorRes?.code === 0) setColorData(colorRes.data?.list || []);
|
||||||
|
if (locRes?.code === 0) setLocationData(locRes.data?.list || []);
|
||||||
|
}).finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 120 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ icon: <ShoppingOutlined />, title: '产品总数', value: summary?.productCount ?? 0 },
|
||||||
|
{ icon: <InboxOutlined />, title: '总匹数', value: summary?.totalPieces ?? 0 },
|
||||||
|
{ icon: <DatabaseOutlined />, title: '总盘数', value: summary?.totalRolls ?? 0 },
|
||||||
|
{ icon: <DollarOutlined />, title: '库存总值(成本)', value: `¥${summary?.totalCostValue ?? '0'}` },
|
||||||
|
{ icon: <RiseOutlined />, title: '库存总值(销售)', value: `¥${summary?.totalSalesValue ?? '0'}` },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Col span={5}><StatisticCard statistic={{ title: '产品总数', value: summary.productCount || 0 }} /></Col>
|
{stats.map((s) => (
|
||||||
<Col span={5}><StatisticCard statistic={{ title: '总匹数', value: summary.totalPieces || 0 }} /></Col>
|
<Col xs={24} sm={12} lg={4} xl={4} key={s.title} style={{ flex: 1 }}>
|
||||||
<Col span={5}><StatisticCard statistic={{ title: '总盘数', value: summary.totalRolls || 0 }} /></Col>
|
<Card bordered={false} styles={{ body: { padding: '20px 18px' } }}>
|
||||||
<Col span={5}><StatisticCard statistic={{ title: '库存总值(成本)', value: summary.totalCostValue || '0', prefix: '¥' }} /></Col>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||||
<Col span={4}><StatisticCard statistic={{ title: '库存总值(销售)', value: summary.totalSalesValue || '0', prefix: '¥' }} /></Col>
|
<span style={{
|
||||||
|
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||||||
|
width: 34, height: 34, borderRadius: 8,
|
||||||
|
background: 'rgba(0,82,255,0.06)', color: '#0052FF', fontSize: 16,
|
||||||
|
}}>
|
||||||
|
{s.icon}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: '#64748B' }}>{s.title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, color: '#0F172A' }}>{s.value}</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Table
|
<Card title="按颜色分类统计" bordered={false}>
|
||||||
title={() => '按颜色分类统计'}
|
<Table<StockGroup>
|
||||||
dataSource={colorData}
|
dataSource={colorData}
|
||||||
rowKey="name"
|
rowKey="name"
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '颜色', dataIndex: 'name' },
|
{ title: '颜色', dataIndex: 'name' },
|
||||||
{ title: '产品数', dataIndex: 'count' },
|
...groupColumns.slice(1),
|
||||||
{ title: '库存量', dataIndex: 'quantity' },
|
]}
|
||||||
]}
|
pagination={false}
|
||||||
pagination={false}
|
size="small"
|
||||||
size="small"
|
/>
|
||||||
/>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Table
|
<Card title="按位置分布统计" bordered={false}>
|
||||||
title={() => '按位置分布统计'}
|
<Table<StockGroup>
|
||||||
dataSource={locationData}
|
dataSource={locationData}
|
||||||
rowKey="name"
|
rowKey="name"
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '存放位置', dataIndex: 'name' },
|
{ title: '存放位置', dataIndex: 'name' },
|
||||||
{ title: '产品数', dataIndex: 'count' },
|
...groupColumns.slice(1),
|
||||||
{ title: '库存量', dataIndex: 'quantity' },
|
]}
|
||||||
]}
|
pagination={false}
|
||||||
pagination={false}
|
size="small"
|
||||||
size="small"
|
/>
|
||||||
/>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
import { listProducts } from '@/services/api';
|
import { listProducts } from '@/services/api';
|
||||||
|
import type { Product } from '@/types/api';
|
||||||
|
|
||||||
const columns: ProColumns[] = [
|
const columns: ProColumns<Product>[] = [
|
||||||
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
|
{ title: '产品名称', dataIndex: 'productName', ellipsis: true },
|
||||||
{ title: '规格型号', dataIndex: 'spec' },
|
{ title: '规格型号', dataIndex: 'spec' },
|
||||||
{ title: '颜色', dataIndex: 'color' },
|
{ title: '颜色', dataIndex: 'color' },
|
||||||
@ -14,7 +15,7 @@ const columns: ProColumns[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const StocksPage: React.FC = () => (
|
const StocksPage: React.FC = () => (
|
||||||
<ProTable
|
<ProTable<Product>
|
||||||
headerTitle="库存查询"
|
headerTitle="库存查询"
|
||||||
rowKey="productId"
|
rowKey="productId"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@ -1,19 +1,24 @@
|
|||||||
import { LoginForm, ProFormText } from '@ant-design/pro-components';
|
import React, { useState } from 'react';
|
||||||
import { history, useModel } from '@umijs/max';
|
import { history, useModel } from '@umijs/max';
|
||||||
import { message } from 'antd';
|
import { Button, Form, Input, message } from 'antd';
|
||||||
|
import { LockOutlined, UserOutlined, HomeOutlined } from '@ant-design/icons';
|
||||||
import { getUserInfo, login } from '@/services/api';
|
import { getUserInfo, login } from '@/services/api';
|
||||||
|
import type { LoginParams } from '@/types/api';
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const { setInitialState } = useModel('@@initialState');
|
const { setInitialState } = useModel('@@initialState');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (values: { username: string; password: string }) => {
|
const handleSubmit = async (values: LoginParams) => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await login(values);
|
const res = await login(values);
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
localStorage.setItem('token', res.data.token);
|
localStorage.setItem('token', res.data.token);
|
||||||
|
localStorage.setItem(
|
||||||
// Important: load authorized menu paths immediately after login.
|
'tenantId',
|
||||||
// Otherwise, first render may temporarily show full menus until refresh.
|
res.data.userInfo?.tenantId || values.tenantId || 't_default_001',
|
||||||
|
);
|
||||||
const infoRes = await getUserInfo();
|
const infoRes = await getUserInfo();
|
||||||
if (infoRes?.code === 0 && infoRes.data) {
|
if (infoRes?.code === 0 && infoRes.data) {
|
||||||
await setInitialState({
|
await setInitialState({
|
||||||
@ -26,39 +31,206 @@ const LoginPage: React.FC = () => {
|
|||||||
menuPaths: [],
|
menuPaths: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success('登录成功');
|
message.success('登录成功');
|
||||||
history.push('/dashboard');
|
history.push('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
message.error(res.msg || '登录失败');
|
message.error(res.msg || '登录失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
message.error('网络异常,请稍后重试');
|
message.error('网络异常,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#f0f2f5' }}>
|
<div style={styles.wrapper}>
|
||||||
<LoginForm
|
{/* Background decorations */}
|
||||||
title="muyuqingfeng仓储管理系统"
|
<div style={styles.bgGlow1} />
|
||||||
subTitle="Warehouse Management System"
|
<div style={styles.bgGlow2} />
|
||||||
onFinish={handleSubmit}
|
<div style={styles.dotPattern} />
|
||||||
>
|
|
||||||
<ProFormText
|
<div style={styles.card}>
|
||||||
name="username"
|
{/* Logo area */}
|
||||||
fieldProps={{ size: 'large' }}
|
<div style={styles.logoSection}>
|
||||||
placeholder="用户名"
|
<div style={styles.logoIcon}>
|
||||||
rules={[{ required: true, message: '请输入用户名' }]}
|
<span style={styles.logoLetter}>
|
||||||
/>
|
<ruby>木<rt>キ</rt></ruby>
|
||||||
<ProFormText.Password
|
<ruby>羽<rt>ハネ</rt></ruby>
|
||||||
name="password"
|
</span>
|
||||||
fieldProps={{ size: 'large' }}
|
</div>
|
||||||
placeholder="密码"
|
<h1 style={styles.title}>
|
||||||
rules={[{ required: true, message: '请输入密码' }]}
|
<ruby>木羽<rt style={{ fontSize: 11, color: '#94A3B8', fontWeight: 400, letterSpacing: 4 }}>キハネ</rt></ruby>
|
||||||
/>
|
<ruby>清風<rt style={{ fontSize: 11, color: '#94A3B8', fontWeight: 400, letterSpacing: 2 }}>セイフウ</rt></ruby>
|
||||||
</LoginForm>
|
</h1>
|
||||||
|
<p style={styles.subtitle}>仓储管理系统</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form<LoginParams>
|
||||||
|
size="large"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined style={{ color: '#94A3B8' }} />}
|
||||||
|
placeholder="用户名"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: '#94A3B8' }} />}
|
||||||
|
placeholder="密码"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="tenantId" rules={[{ required: true, message: '请输入租户ID' }]}>
|
||||||
|
<Input
|
||||||
|
prefix={<HomeOutlined style={{ color: '#94A3B8' }} />}
|
||||||
|
placeholder="租户ID(必填)"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
block
|
||||||
|
style={styles.submitBtn}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<p style={styles.footer}>
|
||||||
|
Warehouse Management System
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
wrapper: {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(135deg, #0F172A 0%, #1E293B 50%, #0F172A 100%)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
bgGlow1: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-20%',
|
||||||
|
right: '-10%',
|
||||||
|
width: '600px',
|
||||||
|
height: '600px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'radial-gradient(circle, rgba(0,82,255,0.15) 0%, transparent 70%)',
|
||||||
|
filter: 'blur(80px)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
bgGlow2: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-15%',
|
||||||
|
left: '-5%',
|
||||||
|
width: '500px',
|
||||||
|
height: '500px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'radial-gradient(circle, rgba(77,124,255,0.1) 0%, transparent 70%)',
|
||||||
|
filter: 'blur(80px)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
dotPattern: {
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.03) 1px, transparent 1px)',
|
||||||
|
backgroundSize: '32px 32px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 420,
|
||||||
|
margin: '0 24px',
|
||||||
|
padding: '48px 40px 40px',
|
||||||
|
background: 'rgba(255,255,255,0.97)',
|
||||||
|
borderRadius: 20,
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.1)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
},
|
||||||
|
logoSection: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 36,
|
||||||
|
},
|
||||||
|
logoIcon: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 18,
|
||||||
|
background: 'linear-gradient(135deg, #0052FF, #4D7CFF)',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,82,255,0.35)',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
logoLetter: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
lineHeight: '36px',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: 400,
|
||||||
|
fontFamily: '"Calistoga", Georgia, serif',
|
||||||
|
color: '#0F172A',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
margin: '6px 0 0',
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#64748B',
|
||||||
|
fontFamily: '"JetBrains Mono", monospace',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid #E2E8F0',
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
submitBtn: {
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: 'linear-gradient(135deg, #0052FF, #4D7CFF)',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 4px 14px rgba(0,82,255,0.25)',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
marginTop: 24,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#94A3B8',
|
||||||
|
fontFamily: '"JetBrains Mono", monospace',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default LoginPage;
|
export default LoginPage;
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
import { PageContainer } from '@ant-design/pro-components';
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
import { Table, Input, Button, message, Card } from 'antd';
|
import { Table, Input, Button, message, Card, Space } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { listConfigs, updateConfig } from '@/services/api';
|
import { listConfigs, updateConfig } from '@/services/api';
|
||||||
|
import type { SystemConfig } from '@/types/api';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
const ConfigsPage: React.FC = () => {
|
const ConfigsPage: React.FC = () => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<SystemConfig[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editingKey, setEditingKey] = useState('');
|
const [editingKey, setEditingKey] = useState('');
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
@ -27,12 +29,14 @@ const ConfigsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns: ColumnsType<SystemConfig> = [
|
||||||
{ title: '配置名称', dataIndex: 'configName', key: 'configName' },
|
{ title: '配置名称', dataIndex: 'configName', key: 'configName' },
|
||||||
{ title: '配置键', dataIndex: 'configKey', key: 'configKey' },
|
{ title: '配置键', dataIndex: 'configKey', key: 'configKey' },
|
||||||
{
|
{
|
||||||
title: '配置值', dataIndex: 'configValue', key: 'configValue',
|
title: '配置值',
|
||||||
render: (v: string, record: any) =>
|
dataIndex: 'configValue',
|
||||||
|
key: 'configValue',
|
||||||
|
render: (v, record) =>
|
||||||
editingKey === record.configKey
|
editingKey === record.configKey
|
||||||
? <Input value={editValue} onChange={(e) => setEditValue(e.target.value)} style={{ width: 300 }} />
|
? <Input value={editValue} onChange={(e) => setEditValue(e.target.value)} style={{ width: 300 }} />
|
||||||
: v,
|
: v,
|
||||||
@ -40,21 +44,30 @@ const ConfigsPage: React.FC = () => {
|
|||||||
{ title: '分组', dataIndex: 'configGroup', key: 'configGroup' },
|
{ title: '分组', dataIndex: 'configGroup', key: 'configGroup' },
|
||||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '操作', key: 'action',
|
title: '操作',
|
||||||
render: (_: any, record: any) =>
|
key: 'action',
|
||||||
editingKey === record.configKey
|
render: (_, record) =>
|
||||||
? <>
|
editingKey === record.configKey ? (
|
||||||
<a onClick={() => handleSave(record.configKey)} style={{ marginRight: 8 }}>保存</a>
|
<Space>
|
||||||
<a onClick={() => setEditingKey('')}>取消</a>
|
<a onClick={() => handleSave(record.configKey)}>保存</a>
|
||||||
</>
|
<a onClick={() => setEditingKey('')}>取消</a>
|
||||||
: <a onClick={() => { setEditingKey(record.configKey); setEditValue(record.configValue); }}>编辑</a>,
|
</Space>
|
||||||
|
) : (
|
||||||
|
<a onClick={() => { setEditingKey(record.configKey); setEditValue(record.configValue); }}>编辑</a>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Card>
|
<Card bordered={false}>
|
||||||
<Table columns={columns} dataSource={data} rowKey="configKey" loading={loading} pagination={false} />
|
<Table<SystemConfig>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="configKey"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
import { Tag } from 'antd';
|
import { Tag } from 'antd';
|
||||||
import { listLogs } from '@/services/api';
|
import { listLogs } from '@/services/api';
|
||||||
|
import type { OperationLog } from '@/types/api';
|
||||||
|
|
||||||
const columns: ProColumns[] = [
|
const columns: ProColumns<OperationLog>[] = [
|
||||||
{ title: '操作人', dataIndex: 'username' },
|
{ title: '操作人', dataIndex: 'username' },
|
||||||
{ title: '模块', dataIndex: 'module' },
|
{ title: '模块', dataIndex: 'module' },
|
||||||
{ title: '操作', dataIndex: 'operation' },
|
{ title: '操作', dataIndex: 'operation' },
|
||||||
@ -11,21 +12,26 @@ const columns: ProColumns[] = [
|
|||||||
{ title: 'IP', dataIndex: 'ip', search: false },
|
{ title: 'IP', dataIndex: 'ip', search: false },
|
||||||
{ title: '耗时(ms)', dataIndex: 'duration', search: false },
|
{ title: '耗时(ms)', dataIndex: 'duration', search: false },
|
||||||
{
|
{
|
||||||
title: '结果', dataIndex: 'status', search: false,
|
title: '结果',
|
||||||
|
dataIndex: 'status',
|
||||||
|
search: false,
|
||||||
render: (_, r) => <Tag color={r.status === 1 ? 'success' : 'error'}>{r.status === 1 ? '成功' : '失败'}</Tag>,
|
render: (_, r) => <Tag color={r.status === 1 ? 'success' : 'error'}>{r.status === 1 ? '成功' : '失败'}</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '操作时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
|
{ title: '操作时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LogsPage: React.FC = () => (
|
const LogsPage: React.FC = () => (
|
||||||
<ProTable
|
<ProTable<OperationLog>
|
||||||
headerTitle="操作日志"
|
headerTitle="操作日志"
|
||||||
rowKey="logId"
|
rowKey="logId"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
request={async (params) => {
|
request={async (params) => {
|
||||||
const res = await listLogs({
|
const res = await listLogs({
|
||||||
page: params.current, pageSize: params.pageSize,
|
page: params.current,
|
||||||
username: params.username, module: params.module, operation: params.operation,
|
pageSize: params.pageSize,
|
||||||
|
username: params.username,
|
||||||
|
module: params.module,
|
||||||
|
operation: params.operation,
|
||||||
});
|
});
|
||||||
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,43 +1,19 @@
|
|||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import { PageContainer } from '@ant-design/pro-components';
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
import { Button, Table, Tag, message, Popconfirm } from 'antd';
|
import { Button, Card, Table, Tag, message, Popconfirm } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { listMenus, deleteMenu } from '@/services/api';
|
import { listMenus, deleteMenu } from '@/services/api';
|
||||||
|
import type { Menu } from '@/types/api';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
const menuTypeMap: Record<number, { text: string; color: string }> = {
|
const MENU_TYPE_MAP: Record<number, { text: string; color: string }> = {
|
||||||
1: { text: '目录', color: 'blue' },
|
1: { text: '目录', color: 'blue' },
|
||||||
2: { text: '菜单', color: 'green' },
|
2: { text: '菜单', color: 'green' },
|
||||||
3: { text: '按钮', color: 'orange' },
|
3: { text: '按钮', color: 'orange' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: '菜单名称', dataIndex: 'menuName', key: 'menuName' },
|
|
||||||
{ title: '路由', dataIndex: 'path', key: 'path' },
|
|
||||||
{ title: '图标', dataIndex: 'icon', key: 'icon' },
|
|
||||||
{
|
|
||||||
title: '类型', dataIndex: 'menuType', key: 'menuType',
|
|
||||||
render: (v: number) => {
|
|
||||||
const t = menuTypeMap[v] || menuTypeMap[1];
|
|
||||||
return <Tag color={t.color}>{t.text}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder' },
|
|
||||||
{
|
|
||||||
title: '操作', key: 'action',
|
|
||||||
render: (_: any, record: any) => [
|
|
||||||
<a key="edit" style={{ marginRight: 8 }}>编辑</a>,
|
|
||||||
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
|
|
||||||
const res = await deleteMenu(record.menuId);
|
|
||||||
if (res?.code === 0) message.success('删除成功');
|
|
||||||
}}>
|
|
||||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
|
||||||
</Popconfirm>,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const MenusPage: React.FC = () => {
|
const MenusPage: React.FC = () => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<Menu[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@ -49,17 +25,52 @@ const MenusPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => { fetchData(); }, []);
|
useEffect(() => { fetchData(); }, []);
|
||||||
|
|
||||||
|
const columns: ColumnsType<Menu> = [
|
||||||
|
{ title: '菜单名称', dataIndex: 'menuName', key: 'menuName' },
|
||||||
|
{ title: '路由', dataIndex: 'path', key: 'path' },
|
||||||
|
{ title: '图标', dataIndex: 'icon', key: 'icon' },
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'menuType',
|
||||||
|
key: 'menuType',
|
||||||
|
render: (v: number) => {
|
||||||
|
const t = MENU_TYPE_MAP[v] ?? MENU_TYPE_MAP[1];
|
||||||
|
return <Tag color={t.color}>{t.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_, record) => [
|
||||||
|
<a key="edit" style={{ marginRight: 8 }}>编辑</a>,
|
||||||
|
<Popconfirm
|
||||||
|
key="del"
|
||||||
|
title="确认删除?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
const res = await deleteMenu(record.menuId);
|
||||||
|
if (res?.code === 0) { message.success('删除成功'); fetchData(); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a style={{ color: 'var(--ant-color-error)' }}>删除</a>
|
||||||
|
</Popconfirm>,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Button type="primary" icon={<PlusOutlined />} style={{ marginBottom: 16 }}>新增菜单</Button>
|
<Card bordered={false}>
|
||||||
<Table
|
<Button type="primary" icon={<PlusOutlined />} style={{ marginBottom: 16 }}>新增菜单</Button>
|
||||||
columns={columns}
|
<Table<Menu>
|
||||||
dataSource={data}
|
columns={columns}
|
||||||
rowKey="menuId"
|
dataSource={data}
|
||||||
loading={loading}
|
rowKey="menuId"
|
||||||
childrenColumnName="children"
|
loading={loading}
|
||||||
pagination={false}
|
childrenColumnName="children"
|
||||||
/>
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,38 +1,53 @@
|
|||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormDigit, ProFormTextArea } from '@ant-design/pro-components';
|
import { ActionType, ModalForm, ProColumns, ProFormDigit, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components';
|
||||||
import { Button, message, Popconfirm } from 'antd';
|
import { Button, message, Popconfirm } from 'antd';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { listRoles, createRole, updateRole, deleteRole } from '@/services/api';
|
import { listRoles, createRole, updateRole, deleteRole } from '@/services/api';
|
||||||
|
import type { Role } from '@/types/api';
|
||||||
|
|
||||||
const RolesPage: React.FC = () => {
|
const RolesPage: React.FC = () => {
|
||||||
const actionRef = useRef<ActionType>();
|
const actionRef = useRef<ActionType>();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [currentRow, setCurrentRow] = useState<any>(null);
|
const [currentRow, setCurrentRow] = useState<Role | null>(null);
|
||||||
|
|
||||||
const columns: ProColumns[] = [
|
const columns: ProColumns<Role>[] = [
|
||||||
{ title: '角色名称', dataIndex: 'roleName' },
|
{ title: '角色名称', dataIndex: 'roleName' },
|
||||||
{ title: '角色标识', dataIndex: 'roleKey', search: false },
|
{ title: '角色标识', dataIndex: 'roleKey', search: false },
|
||||||
{ title: '描述', dataIndex: 'roleDesc', search: false, ellipsis: true },
|
{ title: '描述', dataIndex: 'roleDesc', search: false, ellipsis: true },
|
||||||
{ title: '排序', dataIndex: 'sortOrder', search: false },
|
{ title: '排序', dataIndex: 'sortOrder', search: false },
|
||||||
{ title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
|
{ title: '创建时间', dataIndex: 'createdAt', valueType: 'dateTime', search: false },
|
||||||
{
|
{
|
||||||
title: '操作', valueType: 'option',
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
render: (_, record) => [
|
render: (_, record) => [
|
||||||
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); }}>编辑</a>,
|
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); }}>编辑</a>,
|
||||||
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
|
<Popconfirm
|
||||||
const res = await deleteRole(record.roleId);
|
key="del"
|
||||||
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
|
title="确认删除?"
|
||||||
}}>
|
onConfirm={async () => {
|
||||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
const res = await deleteRole(record.roleId);
|
||||||
|
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a style={{ color: 'var(--ant-color-error)' }}>删除</a>
|
||||||
</Popconfirm>,
|
</Popconfirm>,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const formFields = (
|
||||||
|
<>
|
||||||
|
<ProFormText name="roleName" label="角色名称" rules={[{ required: true }]} />
|
||||||
|
<ProFormText name="roleKey" label="角色标识" rules={[{ required: true }]} placeholder="如: admin, warehouse, user" />
|
||||||
|
<ProFormTextArea name="roleDesc" label="描述" />
|
||||||
|
<ProFormDigit name="sortOrder" label="排序" initialValue={0} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProTable
|
<ProTable<Role>
|
||||||
headerTitle="角色管理"
|
headerTitle="角色管理"
|
||||||
actionRef={actionRef}
|
actionRef={actionRef}
|
||||||
rowKey="roleId"
|
rowKey="roleId"
|
||||||
@ -45,27 +60,33 @@ const RolesPage: React.FC = () => {
|
|||||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新增角色</Button>,
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新增角色</Button>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<ModalForm title="新增角色" open={createOpen} onOpenChange={setCreateOpen}
|
<ModalForm
|
||||||
|
title="新增角色"
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
onFinish={async (values) => {
|
onFinish={async (values) => {
|
||||||
const res = await createRole(values);
|
const res = await createRole(values);
|
||||||
if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
|
if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
|
||||||
message.error(res?.msg || '创建失败'); return false;
|
message.error(res?.msg || '创建失败');
|
||||||
}}>
|
return false;
|
||||||
<ProFormText name="roleName" label="角色名称" rules={[{ required: true }]} />
|
}}
|
||||||
<ProFormText name="roleKey" label="角色标识" rules={[{ required: true }]} placeholder="如: admin, warehouse, user" />
|
>
|
||||||
<ProFormTextArea name="roleDesc" label="描述" />
|
{formFields}
|
||||||
<ProFormDigit name="sortOrder" label="排序" initialValue={0} />
|
|
||||||
</ModalForm>
|
</ModalForm>
|
||||||
<ModalForm title="编辑角色" open={editOpen} onOpenChange={setEditOpen} initialValues={currentRow}
|
<ModalForm
|
||||||
|
title="编辑角色"
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
initialValues={currentRow ?? undefined}
|
||||||
onFinish={async (values) => {
|
onFinish={async (values) => {
|
||||||
|
if (!currentRow) return false;
|
||||||
const res = await updateRole(currentRow.roleId, values);
|
const res = await updateRole(currentRow.roleId, values);
|
||||||
if (res?.code === 0) { message.success('更新成功'); actionRef.current?.reload(); return true; }
|
if (res?.code === 0) { message.success('更新成功'); actionRef.current?.reload(); return true; }
|
||||||
message.error(res?.msg || '更新失败'); return false;
|
message.error(res?.msg || '更新失败');
|
||||||
}}>
|
return false;
|
||||||
<ProFormText name="roleName" label="角色名称" />
|
}}
|
||||||
<ProFormText name="roleKey" label="角色标识" />
|
>
|
||||||
<ProFormTextArea name="roleDesc" label="描述" />
|
{formFields}
|
||||||
<ProFormDigit name="sortOrder" label="排序" />
|
|
||||||
</ModalForm>
|
</ModalForm>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,31 +1,36 @@
|
|||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import { ActionType, ProColumns, ProTable, ModalForm, ProFormText, ProFormSelect } from '@ant-design/pro-components';
|
import { ActionType, ModalForm, ProColumns, ProFormSelect, ProFormText, ProTable } from '@ant-design/pro-components';
|
||||||
import { Button, message, Popconfirm, Switch } from 'antd';
|
import { Button, message, Popconfirm, Switch } from 'antd';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { listUsers, createUser, updateUser, deleteUser, updateUserStatus, listRoles } from '@/services/api';
|
import { listUsers, createUser, updateUser, deleteUser, updateUserStatus, listRoles } from '@/services/api';
|
||||||
|
import type { User } from '@/types/api';
|
||||||
|
|
||||||
const UsersPage: React.FC = () => {
|
const UsersPage: React.FC = () => {
|
||||||
const actionRef = useRef<ActionType>();
|
const actionRef = useRef<ActionType>();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [currentRow, setCurrentRow] = useState<any>(null);
|
const [currentRow, setCurrentRow] = useState<User | null>(null);
|
||||||
const [roles, setRoles] = useState<any[]>([]);
|
const [roleOptions, setRoleOptions] = useState<{ label: string; value: string }[]>([]);
|
||||||
|
|
||||||
const fetchRoles = async () => {
|
const fetchRoles = async () => {
|
||||||
const res = await listRoles({ page: 1, pageSize: 100 });
|
const res = await listRoles({ page: 1, pageSize: 100 });
|
||||||
if (res?.code === 0) {
|
if (res?.code === 0) {
|
||||||
setRoles(res.data?.list?.map((r: any) => ({ label: r.roleName, value: r.roleId })) || []);
|
setRoleOptions(
|
||||||
|
res.data?.list?.map((r) => ({ label: r.roleName, value: r.roleId })) || [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ProColumns[] = [
|
const columns: ProColumns<User>[] = [
|
||||||
{ title: '用户名', dataIndex: 'username' },
|
{ title: '用户名', dataIndex: 'username' },
|
||||||
{ title: '真实姓名', dataIndex: 'realName' },
|
{ title: '真实姓名', dataIndex: 'realName' },
|
||||||
{ title: '手机号', dataIndex: 'phone', search: false },
|
{ title: '手机号', dataIndex: 'phone', search: false },
|
||||||
{ title: '邮箱', dataIndex: 'email', search: false },
|
{ title: '邮箱', dataIndex: 'email', search: false },
|
||||||
{ title: '角色', dataIndex: 'roleName', search: false },
|
{ title: '角色', dataIndex: 'roleName', search: false },
|
||||||
{
|
{
|
||||||
title: '状态', dataIndex: 'status', search: false,
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
search: false,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Switch
|
<Switch
|
||||||
checked={record.status === 1}
|
checked={record.status === 1}
|
||||||
@ -38,14 +43,19 @@ const UsersPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{ title: '最后登录', dataIndex: 'lastLoginTime', valueType: 'dateTime', search: false },
|
{ title: '最后登录', dataIndex: 'lastLoginTime', valueType: 'dateTime', search: false },
|
||||||
{
|
{
|
||||||
title: '操作', valueType: 'option',
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
render: (_, record) => [
|
render: (_, record) => [
|
||||||
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); fetchRoles(); }}>编辑</a>,
|
<a key="edit" onClick={() => { setCurrentRow(record); setEditOpen(true); fetchRoles(); }}>编辑</a>,
|
||||||
<Popconfirm key="del" title="确认删除?" onConfirm={async () => {
|
<Popconfirm
|
||||||
const res = await deleteUser(record.userId);
|
key="del"
|
||||||
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
|
title="确认删除?"
|
||||||
}}>
|
onConfirm={async () => {
|
||||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
const res = await deleteUser(record.userId);
|
||||||
|
if (res?.code === 0) { message.success('删除成功'); actionRef.current?.reload(); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a style={{ color: 'var(--ant-color-error)' }}>删除</a>
|
||||||
</Popconfirm>,
|
</Popconfirm>,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -53,13 +63,18 @@ const UsersPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProTable
|
<ProTable<User>
|
||||||
headerTitle="用户管理"
|
headerTitle="用户管理"
|
||||||
actionRef={actionRef}
|
actionRef={actionRef}
|
||||||
rowKey="userId"
|
rowKey="userId"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
request={async (params) => {
|
request={async (params) => {
|
||||||
const res = await listUsers({ page: params.current, pageSize: params.pageSize, username: params.username, realName: params.realName });
|
const res = await listUsers({
|
||||||
|
page: params.current,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
username: params.username,
|
||||||
|
realName: params.realName,
|
||||||
|
});
|
||||||
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
return { data: res?.data?.list || [], total: res?.data?.total || 0, success: res?.code === 0 };
|
||||||
}}
|
}}
|
||||||
toolBarRender={() => [
|
toolBarRender={() => [
|
||||||
@ -75,11 +90,7 @@ const UsersPage: React.FC = () => {
|
|||||||
onOpenChange={setCreateOpen}
|
onOpenChange={setCreateOpen}
|
||||||
onFinish={async (values) => {
|
onFinish={async (values) => {
|
||||||
const res = await createUser(values);
|
const res = await createUser(values);
|
||||||
if (res?.code === 0) {
|
if (res?.code === 0) { message.success('创建成功'); actionRef.current?.reload(); return true; }
|
||||||
message.success('创建成功');
|
|
||||||
actionRef.current?.reload();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
message.error(res?.msg || '创建失败');
|
message.error(res?.msg || '创建失败');
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
@ -89,21 +100,18 @@ const UsersPage: React.FC = () => {
|
|||||||
<ProFormText name="realName" label="真实姓名" rules={[{ required: true }]} />
|
<ProFormText name="realName" label="真实姓名" rules={[{ required: true }]} />
|
||||||
<ProFormText name="phone" label="手机号" />
|
<ProFormText name="phone" label="手机号" />
|
||||||
<ProFormText name="email" label="邮箱" />
|
<ProFormText name="email" label="邮箱" />
|
||||||
<ProFormSelect name="roleId" label="角色" options={roles} rules={[{ required: true }]} />
|
<ProFormSelect name="roleId" label="角色" options={roleOptions} rules={[{ required: true }]} />
|
||||||
</ModalForm>
|
</ModalForm>
|
||||||
|
|
||||||
<ModalForm
|
<ModalForm
|
||||||
title="编辑用户"
|
title="编辑用户"
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
onOpenChange={setEditOpen}
|
onOpenChange={setEditOpen}
|
||||||
initialValues={currentRow}
|
initialValues={currentRow ?? undefined}
|
||||||
onFinish={async (values) => {
|
onFinish={async (values) => {
|
||||||
|
if (!currentRow) return false;
|
||||||
const res = await updateUser(currentRow.userId, values);
|
const res = await updateUser(currentRow.userId, values);
|
||||||
if (res?.code === 0) {
|
if (res?.code === 0) { message.success('更新成功'); actionRef.current?.reload(); return true; }
|
||||||
message.success('更新成功');
|
|
||||||
actionRef.current?.reload();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
message.error(res?.msg || '更新失败');
|
message.error(res?.msg || '更新失败');
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
@ -111,7 +119,7 @@ const UsersPage: React.FC = () => {
|
|||||||
<ProFormText name="realName" label="真实姓名" />
|
<ProFormText name="realName" label="真实姓名" />
|
||||||
<ProFormText name="phone" label="手机号" />
|
<ProFormText name="phone" label="手机号" />
|
||||||
<ProFormText name="email" label="邮箱" />
|
<ProFormText name="email" label="邮箱" />
|
||||||
<ProFormSelect name="roleId" label="角色" options={roles} />
|
<ProFormSelect name="roleId" label="角色" options={roleOptions} />
|
||||||
</ModalForm>
|
</ModalForm>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,177 +1,181 @@
|
|||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
CurrentUser,
|
||||||
|
LoginParams,
|
||||||
|
LoginResult,
|
||||||
|
PaginatedData,
|
||||||
|
PaginationParams,
|
||||||
|
Product,
|
||||||
|
StockSummary,
|
||||||
|
StockGroup,
|
||||||
|
StockCheck,
|
||||||
|
StockAdjust,
|
||||||
|
User,
|
||||||
|
Role,
|
||||||
|
Menu,
|
||||||
|
OperationLog,
|
||||||
|
SystemConfig,
|
||||||
|
Relation,
|
||||||
|
RelationHistory,
|
||||||
|
} from '@/types/api';
|
||||||
|
|
||||||
// ==================== Auth ====================
|
// ── Auth ──────────────────────────────────────────
|
||||||
|
|
||||||
export async function login(data: { username: string; password: string }) {
|
export const login = (data: LoginParams) =>
|
||||||
return request('/api/v1/auth/login', { method: 'POST', data });
|
request<ApiResponse<LoginResult>>('/api/v1/auth/login', { method: 'POST', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout() {
|
export const logout = () =>
|
||||||
return request('/api/v1/auth/logout', { method: 'POST' });
|
request<ApiResponse>('/api/v1/auth/logout', { method: 'POST' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserInfo() {
|
export const getUserInfo = () =>
|
||||||
return request('/api/v1/auth/info', { method: 'GET' });
|
request<ApiResponse<CurrentUser>>('/api/v1/auth/info', { method: 'GET' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function changePassword(data: { oldPassword: string; newPassword: string }) {
|
export const changePassword = (data: { oldPassword: string; newPassword: string }) =>
|
||||||
return request('/api/v1/auth/password', { method: 'PUT', data });
|
request<ApiResponse>('/api/v1/auth/password', { method: 'PUT', data });
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Users ====================
|
// ── Users ─────────────────────────────────────────
|
||||||
|
|
||||||
export async function listUsers(params: any) {
|
export const listUsers = (params: PaginationParams & { username?: string; realName?: string }) =>
|
||||||
return request('/api/v1/system/users', { method: 'GET', params });
|
request<ApiResponse<PaginatedData<User>>>('/api/v1/system/users', { method: 'GET', params });
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUser(id: string) {
|
export const getUser = (id: string) =>
|
||||||
return request(`/api/v1/system/users/${id}`, { method: 'GET' });
|
request<ApiResponse<User>>(`/api/v1/system/users/${id}`, { method: 'GET' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function createUser(data: any) {
|
export const createUser = (data: Partial<User> & { password?: string }) =>
|
||||||
return request('/api/v1/system/users', { method: 'POST', data });
|
request<ApiResponse>('/api/v1/system/users', { method: 'POST', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateUser(id: string, data: any) {
|
export const updateUser = (id: string, data: Partial<User>) =>
|
||||||
return request(`/api/v1/system/users/${id}`, { method: 'PUT', data });
|
request<ApiResponse>(`/api/v1/system/users/${id}`, { method: 'PUT', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteUser(id: string) {
|
export const deleteUser = (id: string) =>
|
||||||
return request(`/api/v1/system/users/${id}`, { method: 'DELETE' });
|
request<ApiResponse>(`/api/v1/system/users/${id}`, { method: 'DELETE' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateUserStatus(id: string, status: number) {
|
export const updateUserStatus = (id: string, status: number) =>
|
||||||
return request(`/api/v1/system/users/${id}/status`, { method: 'PUT', data: { status } });
|
request<ApiResponse>(`/api/v1/system/users/${id}/status`, { method: 'PUT', data: { status } });
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Roles ====================
|
// ── Roles ─────────────────────────────────────────
|
||||||
|
|
||||||
export async function listRoles(params?: any) {
|
export const listRoles = (params?: PaginationParams & { roleName?: string }) =>
|
||||||
return request('/api/v1/system/roles', { method: 'GET', params });
|
request<ApiResponse<PaginatedData<Role>>>('/api/v1/system/roles', { method: 'GET', params });
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRole(data: any) {
|
export const createRole = (data: Partial<Role>) =>
|
||||||
return request('/api/v1/system/roles', { method: 'POST', data });
|
request<ApiResponse>('/api/v1/system/roles', { method: 'POST', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateRole(id: string, data: any) {
|
export const updateRole = (id: string, data: Partial<Role>) =>
|
||||||
return request(`/api/v1/system/roles/${id}`, { method: 'PUT', data });
|
request<ApiResponse>(`/api/v1/system/roles/${id}`, { method: 'PUT', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteRole(id: string) {
|
export const deleteRole = (id: string) =>
|
||||||
return request(`/api/v1/system/roles/${id}`, { method: 'DELETE' });
|
request<ApiResponse>(`/api/v1/system/roles/${id}`, { method: 'DELETE' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function setRolePermissions(id: string, menuIds: string[]) {
|
export const setRolePermissions = (id: string, menuIds: string[]) =>
|
||||||
return request(`/api/v1/system/roles/${id}/permissions`, { method: 'PUT', data: { menuIds } });
|
request<ApiResponse>(`/api/v1/system/roles/${id}/permissions`, { method: 'PUT', data: { menuIds } });
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Menus ====================
|
// ── Menus ─────────────────────────────────────────
|
||||||
|
|
||||||
export async function listMenus() {
|
export const listMenus = () =>
|
||||||
return request('/api/v1/system/menus', { method: 'GET' });
|
request<ApiResponse<PaginatedData<Menu>>>('/api/v1/system/menus', { method: 'GET' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function createMenu(data: any) {
|
export const createMenu = (data: Partial<Menu>) =>
|
||||||
return request('/api/v1/system/menus', { method: 'POST', data });
|
request<ApiResponse>('/api/v1/system/menus', { method: 'POST', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateMenu(id: string, data: any) {
|
export const updateMenu = (id: string, data: Partial<Menu>) =>
|
||||||
return request(`/api/v1/system/menus/${id}`, { method: 'PUT', data });
|
request<ApiResponse>(`/api/v1/system/menus/${id}`, { method: 'PUT', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteMenu(id: string) {
|
export const deleteMenu = (id: string) =>
|
||||||
return request(`/api/v1/system/menus/${id}`, { method: 'DELETE' });
|
request<ApiResponse>(`/api/v1/system/menus/${id}`, { method: 'DELETE' });
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Logs ====================
|
// ── Logs ──────────────────────────────────────────
|
||||||
|
|
||||||
export async function listLogs(params: any) {
|
export const listLogs = (params: PaginationParams & { username?: string; module?: string; operation?: string }) =>
|
||||||
return request('/api/v1/system/logs', { method: 'GET', params });
|
request<ApiResponse<PaginatedData<OperationLog>>>('/api/v1/system/logs', { method: 'GET', params });
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Configs ====================
|
// ── Configs ───────────────────────────────────────
|
||||||
|
|
||||||
export async function listConfigs() {
|
export const listConfigs = () =>
|
||||||
return request('/api/v1/system/configs', { method: 'GET' });
|
request<ApiResponse<PaginatedData<SystemConfig>>>('/api/v1/system/configs', { method: 'GET' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateConfig(key: string, value: string) {
|
export const updateConfig = (key: string, value: string) =>
|
||||||
return request(`/api/v1/system/configs/${key}`, { method: 'PUT', data: { configValue: value } });
|
request<ApiResponse>(`/api/v1/system/configs/${key}`, { method: 'PUT', data: { configValue: value } });
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Products ====================
|
// ── Products ──────────────────────────────────────
|
||||||
|
|
||||||
export async function listProducts(params: any) {
|
export const listProducts = (params: PaginationParams & { productName?: string; spec?: string; color?: string; location?: string }) =>
|
||||||
return request('/api/v1/inventory/products', { method: 'GET', params });
|
request<ApiResponse<PaginatedData<Product>>>('/api/v1/inventory/products', { method: 'GET', params });
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProduct(id: string) {
|
export const getProduct = (id: string) =>
|
||||||
return request(`/api/v1/inventory/products/${id}`, { method: 'GET' });
|
request<ApiResponse<Product>>(`/api/v1/inventory/products/${id}`, { method: 'GET' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function createProduct(data: any) {
|
export const createProduct = (data: Partial<Product>) =>
|
||||||
return request('/api/v1/inventory/products', { method: 'POST', data });
|
request<ApiResponse>('/api/v1/inventory/products', { method: 'POST', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateProduct(id: string, data: any) {
|
export const updateProduct = (id: string, data: Partial<Product>) =>
|
||||||
return request(`/api/v1/inventory/products/${id}`, { method: 'PUT', data });
|
request<ApiResponse>(`/api/v1/inventory/products/${id}`, { method: 'PUT', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteProduct(id: string) {
|
export const deleteProduct = (id: string) =>
|
||||||
return request(`/api/v1/inventory/products/${id}`, { method: 'DELETE' });
|
request<ApiResponse>(`/api/v1/inventory/products/${id}`, { method: 'DELETE' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportProducts() {
|
export const exportProducts = () =>
|
||||||
return request('/api/v1/inventory/products/export', { method: 'GET', responseType: 'blob' });
|
request('/api/v1/inventory/products/export', { method: 'GET', responseType: 'blob' });
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Stocks ====================
|
// ── Stocks ────────────────────────────────────────
|
||||||
|
|
||||||
export async function getStockSummary() {
|
export const getStockSummary = () =>
|
||||||
return request('/api/v1/inventory/stocks/summary', { method: 'GET' });
|
request<ApiResponse<StockSummary>>('/api/v1/inventory/stocks/summary', { method: 'GET' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStockByColor() {
|
export const getStockByColor = () =>
|
||||||
return request('/api/v1/inventory/stocks/by-color', { method: 'GET' });
|
request<ApiResponse<PaginatedData<StockGroup>>>('/api/v1/inventory/stocks/by-color', { method: 'GET' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStockByLocation() {
|
export const getStockByLocation = () =>
|
||||||
return request('/api/v1/inventory/stocks/by-location', { method: 'GET' });
|
request<ApiResponse<PaginatedData<StockGroup>>>('/api/v1/inventory/stocks/by-location', { method: 'GET' });
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Stock Checks ====================
|
// ── Stock Checks ──────────────────────────────────
|
||||||
|
|
||||||
export async function listStockChecks(params: any) {
|
export const listStockChecks = (params: PaginationParams) =>
|
||||||
return request('/api/v1/inventory/checks', { method: 'GET', params });
|
request<ApiResponse<PaginatedData<StockCheck>>>('/api/v1/inventory/checks', { method: 'GET', params });
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStockCheck(id: string) {
|
export const getStockCheck = (id: string) =>
|
||||||
return request(`/api/v1/inventory/checks/${id}`, { method: 'GET' });
|
request<ApiResponse<StockCheck>>(`/api/v1/inventory/checks/${id}`, { method: 'GET' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function createStockCheck(data: any) {
|
export const createStockCheck = (data: Partial<StockCheck>) =>
|
||||||
return request('/api/v1/inventory/checks', { method: 'POST', data });
|
request<ApiResponse>('/api/v1/inventory/checks', { method: 'POST', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function confirmStockCheck(id: string) {
|
export const confirmStockCheck = (id: string) =>
|
||||||
return request(`/api/v1/inventory/checks/${id}/confirm`, { method: 'POST' });
|
request<ApiResponse>(`/api/v1/inventory/checks/${id}/confirm`, { method: 'POST' });
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Stock Adjusts ====================
|
// ── Stock Adjusts ─────────────────────────────────
|
||||||
|
|
||||||
export async function listStockAdjusts(params: any) {
|
export const listStockAdjusts = (params: PaginationParams) =>
|
||||||
return request('/api/v1/inventory/adjusts', { method: 'GET', params });
|
request<ApiResponse<PaginatedData<StockAdjust>>>('/api/v1/inventory/adjusts', { method: 'GET', params });
|
||||||
}
|
|
||||||
|
|
||||||
export async function getStockAdjust(id: string) {
|
export const getStockAdjust = (id: string) =>
|
||||||
return request(`/api/v1/inventory/adjusts/${id}`, { method: 'GET' });
|
request<ApiResponse<StockAdjust>>(`/api/v1/inventory/adjusts/${id}`, { method: 'GET' });
|
||||||
}
|
|
||||||
|
|
||||||
export async function createStockAdjust(data: any) {
|
export const createStockAdjust = (data: Partial<StockAdjust>) =>
|
||||||
return request('/api/v1/inventory/adjusts', { method: 'POST', data });
|
request<ApiResponse>('/api/v1/inventory/adjusts', { method: 'POST', data });
|
||||||
}
|
|
||||||
|
|
||||||
export async function approveStockAdjust(id: string, action: number) {
|
export const approveStockAdjust = (id: string, action: number) =>
|
||||||
return request(`/api/v1/inventory/adjusts/${id}/approve`, { method: 'POST', data: { action } });
|
request<ApiResponse>(`/api/v1/inventory/adjusts/${id}/approve`, { method: 'POST', data: { action } });
|
||||||
}
|
|
||||||
|
// ── CRM Relations ─────────────────────────────────
|
||||||
|
|
||||||
|
export const listUpstream = (depth = 1) =>
|
||||||
|
request<ApiResponse<PaginatedData<Relation>>>('/api/v1/crm/graph/upstream', { method: 'GET', params: { depth } });
|
||||||
|
|
||||||
|
export const listDownstream = (depth = 1) =>
|
||||||
|
request<ApiResponse<PaginatedData<Relation>>>('/api/v1/crm/graph/downstream', { method: 'GET', params: { depth } });
|
||||||
|
|
||||||
|
export const createRelation = (data: {
|
||||||
|
fromTenantId: string;
|
||||||
|
toTenantId: string;
|
||||||
|
relationType: string;
|
||||||
|
validFrom?: string;
|
||||||
|
validTo?: string;
|
||||||
|
}) =>
|
||||||
|
request<ApiResponse>('/api/v1/crm/relationships', { method: 'POST', data });
|
||||||
|
|
||||||
|
export const updateRelation = (id: string, data: { status: number; validFrom?: string; validTo?: string }) =>
|
||||||
|
request<ApiResponse>(`/api/v1/crm/relationships/${id}`, { method: 'PUT', data });
|
||||||
|
|
||||||
|
export const listRelationHistory = (params?: PaginationParams) =>
|
||||||
|
request<ApiResponse<PaginatedData<RelationHistory>>>('/api/v1/crm/relationships/history', { method: 'GET', params });
|
||||||
|
|||||||
157
src/types/api.ts
Normal file
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