feat: init ai-platform - 源码 + 配置
This commit is contained in:
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
build/
|
||||||
|
*.local
|
||||||
|
!dist/.gitkeep
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache/
|
||||||
|
.temp/
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Large sample data (use Git LFS if needed)
|
||||||
|
public/bomx/bom-data.json
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>EIOSpace</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3511
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-platform",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.4.0",
|
||||||
|
"antd": "^5.21.0",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.1.1",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^8.0.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import HomePage from '@/pages/HomePage';
|
||||||
|
import SSOLogin from '@/pages/SSOLogin';
|
||||||
|
import AIMCockpit from '@/modules/ai-matching/pages/AIMCockpit';
|
||||||
|
import CostCockpit from '@/modules/cost-analysis/pages/CostCockpit';
|
||||||
|
import KnowledgeGraphHome from '@/modules/knowledge-graph/pages/KnowledgeGraphHome';
|
||||||
|
import SpaceCockpit from '@/modules/knowledge-graph/pages/SpaceCockpit';
|
||||||
|
import BomxReuseHome from '@/modules/bomx-reuse-analyzer/pages/BomxReuseHome';
|
||||||
|
import CostCockpitFull from '@/modules/order-cost-analysis/pages/CostCockpitFull';
|
||||||
|
import AIMCockpitFull from '@/modules/order-cost-analysis/pages/AIMCockpitFull';
|
||||||
|
import BexellSchedulingApp from '@/modules/bexell-scheduling-ai/App';
|
||||||
|
import SanruiAIDesignHome from '@/modules/sanrui-ai-design/pages/SanruiAIDesignHome';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/sso-login" element={<SSOLogin />} />
|
||||||
|
<Route path="/ai-matching" element={<AIMCockpit />} />
|
||||||
|
<Route path="/cost-analysis" element={<CostCockpit />} />
|
||||||
|
<Route path="/knowledge-graph" element={<KnowledgeGraphHome />} />
|
||||||
|
<Route path="/knowledge-graph/space" element={<SpaceCockpit />} />
|
||||||
|
<Route path="/bomx-reuse-analyzer" element={<BomxReuseHome />} />
|
||||||
|
<Route path="/order-cost-analysis" element={<CostCockpitFull />} />
|
||||||
|
<Route path="/order-cost-analysis/aim" element={<AIMCockpitFull />} />
|
||||||
|
<Route path="/bexell-scheduling-ai/*" element={<BexellSchedulingApp />} />
|
||||||
|
<Route path="/sanrui-ai-design" element={<SanruiAIDesignHome />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
shadcn/ui CSS 变量体系(由 ThemeContext 动态切换)
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 222 47% 5%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222 47% 8%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222 47% 8%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222 47% 5%;
|
||||||
|
--secondary: 217 33% 17%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217 33% 17%;
|
||||||
|
--muted-foreground: 215 20% 65%;
|
||||||
|
--accent: 217 33% 17%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--border: 217 33% 17%;
|
||||||
|
--input: 217 33% 17%;
|
||||||
|
--ring: 212 68% 58%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--color-temp: 211 85% 52%;
|
||||||
|
--color-gas: 252 65% 58%;
|
||||||
|
--color-valve: 32 90% 52%;
|
||||||
|
--color-struct: 160 55% 42%;
|
||||||
|
--shadow-xs: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
|
||||||
|
--shadow-sm: 0 2px 8px rgba(0,0,0,0.07), 0 1px 3px rgba(0,0,0,0.04);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.08), 0 2px 6px rgba(0,0,0,0.04);
|
||||||
|
--shadow-lg: 0 8px 32px rgba(0,0,0,0.10), 0 4px 12px rgba(0,0,0,0.05);
|
||||||
|
--transition-smooth: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-spring: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: transparent;
|
||||||
|
background-image: none;
|
||||||
|
color: inherit;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #000a1e;
|
||||||
|
color: #a8d8ff;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: -apple-system, 'SF Pro Display', 'SF Pro Text', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
Apple 风格工具类(用于浅色主题模块)
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
@layer utilities {
|
||||||
|
.card-glass {
|
||||||
|
background-color: rgba(255,255,255,0.88);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255,255,255,0.7);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.card-solid {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e8e8ed;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.card-elevated {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid rgba(232,232,237,0.6);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.sidebar-bg {
|
||||||
|
background-color: rgba(248,248,250,0.98);
|
||||||
|
backdrop-filter: blur(20px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||||
|
}
|
||||||
|
.text-gradient-primary {
|
||||||
|
background-image: linear-gradient(135deg, hsl(var(--primary)), hsl(252 70% 58%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
.list-card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e8e8ed;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
.list-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.list-card-active {
|
||||||
|
background-color: #f0f7ff;
|
||||||
|
border-color: rgba(10,122,255,0.3);
|
||||||
|
box-shadow: 0 2px 10px rgba(10,122,255,0.10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
太空舱主题 CSS — 动画关键帧
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@keyframes space-border-scan {
|
||||||
|
0% { left: -100%; }
|
||||||
|
100% { left: 200%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes space-pulse {
|
||||||
|
0%, 100% { opacity: 0.5; box-shadow: 0 0 10px rgba(0,212,255,0.2); }
|
||||||
|
50% { opacity: 1; box-shadow: 0 0 25px rgba(0,212,255,0.6); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes star-twinkle {
|
||||||
|
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||||
|
50% { opacity: 1; transform: scale(1.4); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-left {
|
||||||
|
from { opacity: 0; transform: translateX(-20px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-right-space {
|
||||||
|
from { opacity: 0; transform: translateX(20px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-up {
|
||||||
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes notice-in {
|
||||||
|
from { opacity: 0; transform: translateX(-12px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 10px rgba(0,212,255,0.2); }
|
||||||
|
50% { box-shadow: 0 0 30px rgba(0,212,255,0.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan-line {
|
||||||
|
0% { top: 0%; opacity: 0.6; }
|
||||||
|
100% { top: 100%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拖入模块处理动画 */
|
||||||
|
@keyframes module-spin {
|
||||||
|
0% { transform: rotateY(0deg) scale(1); }
|
||||||
|
30% { transform: rotateY(90deg) scale(0.92); }
|
||||||
|
60% { transform: rotateY(180deg) scale(0.88); }
|
||||||
|
80% { transform: rotateY(270deg) scale(0.92); }
|
||||||
|
100% { transform: rotateY(360deg) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orbit-ring {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes orbit-ring-rev {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(-360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes processing-dots {
|
||||||
|
0%, 20% { opacity: 0.2; transform: scale(0.8); }
|
||||||
|
50% { opacity: 1; transform: scale(1.2); }
|
||||||
|
80%, 100%{ opacity: 0.2; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes content-reveal {
|
||||||
|
from { opacity: 0; transform: translateY(18px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan-sweep {
|
||||||
|
0% { top: -4px; opacity: 0; }
|
||||||
|
10% { opacity: 0.8; }
|
||||||
|
90% { opacity: 0.8; }
|
||||||
|
100% { top: 100%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 知识图谱模块动画 */
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from { opacity: 0; transform: translateX(14px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
太空舱主题 CSS — 动画工具类
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.space-animate-left { animation: slide-in-left 0.4s cubic-bezier(0.4,0,0.2,1); }
|
||||||
|
.space-animate-right { animation: slide-in-right-space 0.4s cubic-bezier(0.4,0,0.2,1); }
|
||||||
|
.space-animate-up { animation: slide-in-up 0.4s cubic-bezier(0.4,0,0.2,1); }
|
||||||
|
.space-pulse { animation: space-pulse 2.5s ease-in-out infinite; }
|
||||||
|
.glow-pulse { animation: glow-pulse 3s ease-in-out infinite; }
|
||||||
|
|
||||||
|
.space-card {
|
||||||
|
background: rgba(0, 20, 60, 0.55);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 212, 255, 0.06), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-scroll::-webkit-scrollbar { width: 4px; }
|
||||||
|
.space-scroll::-webkit-scrollbar-track { background: rgba(0,212,255,0.04); }
|
||||||
|
.space-scroll::-webkit-scrollbar-thumb { background: rgba(0,212,255,0.25); border-radius: 4px; }
|
||||||
|
.space-scroll::-webkit-scrollbar-thumb:hover { background: rgba(0,212,255,0.45); }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(0,212,255,0.2); border-radius: 10px; }
|
||||||
|
|
||||||
|
.module-processing {
|
||||||
|
animation: module-spin 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
perspective: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-reveal {
|
||||||
|
animation: content-reveal 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-node { cursor: pointer; }
|
||||||
|
|
||||||
|
.animate-fade-in-up { animation: fade-in-up 0.25s ease-out; }
|
||||||
|
.animate-slide-in-right { animation: slide-in-right 0.25s ease-out; }
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { ThemeProvider } from '@/shared/context/ThemeContext'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import ThemeToggle from '@/shared/components/ThemeToggle';
|
||||||
|
import { DEMANDS, statusConfig } from '@/shared/data/demands';
|
||||||
|
|
||||||
|
export default function AIMHeader() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [time, setTime] = useState(new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setTime(new Date()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const timeStr = `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`;
|
||||||
|
const dateStr = `${time.getFullYear()}-${pad(time.getMonth()+1)}-${pad(time.getDate())}`;
|
||||||
|
|
||||||
|
const totalDemands = 93;
|
||||||
|
const matchingCount = DEMANDS.filter(d => d.status === 'matching' || d.status === 'matched').length;
|
||||||
|
const pilotCount = DEMANDS.filter(d => d.status === 'pilot' || d.status === 'deployed').length;
|
||||||
|
const newCount = DEMANDS.filter(d => d.status === 'new').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header style={{
|
||||||
|
height: 88,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 24px',
|
||||||
|
background: 'transparent',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10,
|
||||||
|
overflow: 'visible',
|
||||||
|
}}>
|
||||||
|
{/* 顶部扫光线 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: 1,
|
||||||
|
background: 'linear-gradient(90deg, transparent 0%, rgba(0,212,255,0.3) 20%, rgba(0,212,255,0.9) 50%, rgba(0,212,255,0.3) 80%, transparent 100%)',
|
||||||
|
boxShadow: '0 0 12px rgba(0,212,255,0.6)',
|
||||||
|
zIndex: 2,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 底部弧形 */}
|
||||||
|
<svg style={{
|
||||||
|
position: 'absolute', bottom: -26, left: 0,
|
||||||
|
width: '100%', height: 28,
|
||||||
|
pointerEvents: 'none', zIndex: 0, overflow: 'visible',
|
||||||
|
}} viewBox="0 0 1000 28" preserveAspectRatio="none">
|
||||||
|
<path d="M 0,0 C 250,28 750,28 1000,0" fill="none" stroke="rgba(0,212,255,0.45)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<path d="M 0,0 C 250,28 750,28 1000,0" fill="none" stroke="rgba(0,212,255,0.1)" strokeWidth="6" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="0" cy="0" r="2.5" fill="rgba(0,212,255,0.6)" />
|
||||||
|
<circle cx="1000" cy="0" r="2.5" fill="rgba(0,212,255,0.6)" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Logo + 标题 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flex: 1, position: 'relative', zIndex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 46, height: 46, borderRadius: 12,
|
||||||
|
background: 'linear-gradient(135deg, rgba(0,80,180,0.7) 0%, rgba(0,30,80,0.9) 100%)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
border: '1px solid rgba(0,212,255,0.5)',
|
||||||
|
boxShadow: '0 0 20px rgba(0,212,255,0.3), inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<img src="/wenzhou-city-logo.png" alt="温州城市Logo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<h1 style={{
|
||||||
|
color: '#e8f4ff', fontSize: 19, fontWeight: 700,
|
||||||
|
letterSpacing: '0.05em', margin: 0,
|
||||||
|
textShadow: '0 0 24px rgba(0,212,255,0.7), 0 0 48px rgba(0,212,255,0.3)',
|
||||||
|
}}>
|
||||||
|
温州市“AI+制造”智创空间
|
||||||
|
</h1>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '2px 8px', borderRadius: 10,
|
||||||
|
background: 'rgba(0,212,255,0.08)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.35)',
|
||||||
|
fontSize: 12, color: '#00d4ff', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 5, height: 5, borderRadius: '50%',
|
||||||
|
backgroundColor: '#00ff88',
|
||||||
|
boxShadow: '0 0 8px #00ff88',
|
||||||
|
animation: 'space-pulse 2s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 12, margin: 0, letterSpacing: '0.04em', lineHeight: 1.45 }}>
|
||||||
|
<div>需求 · 诊断 · 规划 · 对接 · 落地</div>
|
||||||
|
<div>温州市人工智能局</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<div style={{ width: 1, height: 44, background: 'linear-gradient(to bottom, transparent, rgba(0,212,255,0.2), transparent)', flexShrink: 0, marginRight: 24, position: 'relative', zIndex: 1 }} />
|
||||||
|
|
||||||
|
{/* 统计指标 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'center', position: 'relative', zIndex: 1, marginTop: 24 }}>
|
||||||
|
{[
|
||||||
|
{ label: '入库需求', value: String(totalDemands), color: '#a8d8ff' },
|
||||||
|
{ label: '对接中', value: String(matchingCount), color: statusConfig.matching.color },
|
||||||
|
{ label: '试点落地', value: String(pilotCount), color: statusConfig.pilot.color },
|
||||||
|
{ label: '待对接', value: String(newCount), color: statusConfig.new.color },
|
||||||
|
].map((item, idx) => (
|
||||||
|
<div key={item.label} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center', padding: '6px 18px', borderRadius: 10,
|
||||||
|
background: 'rgba(0,212,255,0.05)', border: '1px solid rgba(0,212,255,0.1)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: item.color, fontSize: 22, fontWeight: 700, lineHeight: 1, textShadow: `0 0 10px ${item.color}80` }}>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, marginTop: 2, letterSpacing: '0.04em' }}>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{idx < 3 && <div style={{ width: 1, height: 28, background: 'rgba(0,212,255,0.1)', margin: '0 3px' }} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右分隔线 */}
|
||||||
|
<div style={{ width: 1, height: 44, background: 'linear-gradient(to bottom, transparent, rgba(0,212,255,0.2), transparent)', flexShrink: 0, marginLeft: 24, position: 'relative', zIndex: 1 }} />
|
||||||
|
|
||||||
|
{/* 时间 + 操作按钮 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, justifyContent: 'flex-end', position: 'relative', zIndex: 1 }}>
|
||||||
|
<ThemeToggle />
|
||||||
|
<button onClick={() => navigate('/')} style={{
|
||||||
|
padding: '4px 12px', borderRadius: 8, cursor: 'pointer',
|
||||||
|
border: '1px solid rgba(0,212,255,0.25)',
|
||||||
|
background: 'rgba(0,212,255,0.06)',
|
||||||
|
color: '#00d4ff', fontSize: 12, fontWeight: 600,
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0,212,255,0.12)'; e.currentTarget.style.borderColor = 'rgba(0,212,255,0.4)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(0,212,255,0.06)'; e.currentTarget.style.borderColor = 'rgba(0,212,255,0.25)'; }}
|
||||||
|
>
|
||||||
|
← 返回平台
|
||||||
|
</button>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ color: '#00d4ff', fontSize: 20, fontWeight: 700, fontVariantNumeric: 'tabular-nums', lineHeight: 1, textShadow: '0 0 16px rgba(0,212,255,0.7)', letterSpacing: '0.05em' }}>
|
||||||
|
{timeStr}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 2, letterSpacing: '0.04em' }}>
|
||||||
|
{dateStr}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(0,212,255,0.55)', fontSize: 11, marginTop: 4, letterSpacing: '0.06em' }}>
|
||||||
|
Powered by EIOS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { COUPONS, couponTypeConfig } from '@/shared/data/coupons';
|
||||||
|
import TechIcon, { type TechIconName } from '@/shared/components/TechIcons';
|
||||||
|
import type { AIDemand } from '@/shared/data/demands';
|
||||||
|
import type { Translator } from '@/shared/data/translators';
|
||||||
|
import { generateDesignDoingReply, searchDesignDoing } from '@/shared/data/designdoing';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: number;
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
thinking: string;
|
||||||
|
content: string;
|
||||||
|
thinkingDone: boolean;
|
||||||
|
done: boolean;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = 'chat' | 'maturity' | 'roadmap' | 'coupons';
|
||||||
|
|
||||||
|
const getTimeStr = () => {
|
||||||
|
const now = new Date();
|
||||||
|
return `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUICK_CMDS = ['AI成熟度问诊', 'Design Doing方法论', '推荐落地方案', '匹配AI翻译官', '查询优惠券', '政策解读'];
|
||||||
|
|
||||||
|
const MATURITY_STEPS = [
|
||||||
|
{ q: '企业是否已部署MES/SCADA/IoT等数据采集系统?', sub: '设备数字化率、联网率如何?数据是否可自动采集?', key: 'data_system', weight: 10, category: '自动化和数字化基础' },
|
||||||
|
{ q: '核心生产/经营数据积累情况如何?', sub: '是否有结构化存储?积累了多长时间?数据质量(完整性/一致性)如何?', key: 'data_quality', weight: 10, category: '数据与知识积累' },
|
||||||
|
{ q: '是否已部署ERP/MES/WMS/PLM等核心业务系统?', sub: '系统间是否实现数据互通?覆盖哪些核心工序?', key: 'it_system', weight: 10, category: '自动化和数字化基础' },
|
||||||
|
{ q: '是否有专职IT/数字化团队?', sub: '团队规模和AI基础通识水平如何?是否有外部数字化服务团队支持?', key: 'it_team', weight: 10, category: '团队与人才基础' },
|
||||||
|
{ q: '核心工艺知识是否有系统化沉淀?', sub: 'SOP/故障案例库/工艺参数优化经验,是纸质文档还是系统内置?是否建有企业知识库?', key: 'knowledge', weight: 10, category: '数据与知识积累' },
|
||||||
|
{ q: '是否已尝试过AI或机器学习项目?', sub: '引入了哪些工具?应用场景、使用频率及效果如何?未引入的原因是什么?', key: 'ai_exp', weight: 10, category: 'AI应用现状' },
|
||||||
|
{ q: '管理层是否有明确的AI转型目标和预算?', sub: '一把手是否亲自参与?是否制定1-2年短期或3-5年中长期AI规划?', key: 'leadership', weight: 10, category: 'AI提升意愿' },
|
||||||
|
{ q: '是否已识别出3个以上清晰的AI应用场景?', sub: '这些场景是单企业需求还是行业共性需求?核心价值和可行性如何评估?', key: 'scenario', weight: 10, category: 'AI应用现状' },
|
||||||
|
{ q: '推进AI应用的主要顾虑是什么?', sub: '技术不成熟/数据安全/投入回报不确定/员工抵触?愿意投入的资源力度?', key: 'concern', weight: 10, category: 'AI提升意愿' },
|
||||||
|
{ q: '是否已有明确的AI算力和部署规划?', sub: '自建/租赁/云边端混合?是否有国产化适配要求?预算规划如何?', key: 'infra', weight: 10, category: 'AI应用现状' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface BottomAIBarProps {
|
||||||
|
selectedDemand: AIDemand | null;
|
||||||
|
selectedTranslator: Translator | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BottomAIBar({ selectedDemand, selectedTranslator }: BottomAIBarProps) {
|
||||||
|
const [tab, setTab] = useState<TabType>('chat');
|
||||||
|
const [messages, setMessages] = useState<Message[]>([{
|
||||||
|
id: 0, role: 'ai',
|
||||||
|
thinking: '',
|
||||||
|
content: '您好!我是温州市“AI+制造”智创空间助手。\n\n我可以帮温州制造企业:\n• AI成熟度问诊评级(Design Doing方法论·10步5维)— 评估数字化基础、数据积累、团队人才、AI应用现状、AI提升意愿\n• 解析真实企业AI需求与场景痛点 — 由AI翻译官将业务语言转译为可交付任务\n• 推荐AI落地规划路线 — 生成从现状诊断到规模推广的分阶段实施路径\n• 匹配温州市AI+制造优惠券与政策支持 — 自动匹配算力券、语料券、咨询券、工具券、应用券\n\n试试快捷指令"Design Doing方法论",或直接提问任意步骤(如"数据采集怎么评估""工艺知识""算力规划"),我会为您详细解答!',
|
||||||
|
thinkingDone: true, done: true, time: getTimeStr(),
|
||||||
|
}]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [maturityAnswers, setMaturityAnswers] = useState<Record<string, boolean | string | null>>({});
|
||||||
|
const [maturityStep, setMaturityStep] = useState(0);
|
||||||
|
const [maturityDone, setMaturityDone] = useState(false);
|
||||||
|
const [claimedCoupons, setClaimedCoupons] = useState<Set<string>>(new Set());
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}, [messages, isTyping]);
|
||||||
|
|
||||||
|
const streamText = useCallback((msgId: number, field: 'thinking' | 'content', fullText: string, charDelay: number, onDone?: () => void) => {
|
||||||
|
let idx = 0;
|
||||||
|
const tick = () => {
|
||||||
|
idx += Math.floor(Math.random() * 2) + 1;
|
||||||
|
const slice = fullText.slice(0, Math.min(idx, fullText.length));
|
||||||
|
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, [field]: slice } : m));
|
||||||
|
if (idx < fullText.length) {
|
||||||
|
streamTimerRef.current = setTimeout(tick, charDelay);
|
||||||
|
} else {
|
||||||
|
if (field === 'thinking') setMessages(prev => prev.map(m => m.id === msgId ? { ...m, thinkingDone: true } : m));
|
||||||
|
else setMessages(prev => prev.map(m => m.id === msgId ? { ...m, done: true } : m));
|
||||||
|
onDone?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
streamTimerRef.current = setTimeout(tick, charDelay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildAIReply = useCallback((text: string): { thinking: string; content: string } => {
|
||||||
|
// Design Doing 方法论知识库检索(优先匹配)
|
||||||
|
const ddSearch = searchDesignDoing(text);
|
||||||
|
if (ddSearch.isOverviewQuery || ddSearch.isMethodologyQuery || ddSearch.matchedSteps.length > 0) {
|
||||||
|
return generateDesignDoingReply(
|
||||||
|
text,
|
||||||
|
selectedDemand ? {
|
||||||
|
company: selectedDemand.company,
|
||||||
|
industry: selectedDemand.industry,
|
||||||
|
maturityLevel: selectedDemand.maturityLevel,
|
||||||
|
maturityScore: selectedDemand.maturityScore,
|
||||||
|
dataReadiness: selectedDemand.dataReadiness,
|
||||||
|
} : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (text.includes('成熟度') || text.includes('问诊')) {
|
||||||
|
return {
|
||||||
|
thinking: `启动温州市AI成熟度评估模型…\n分析企业所在温州重点产业:${selectedDemand?.industry || '温州制造业'}…\n调取温州市“人工智能+制造”典型场景参考指引…\n结合问诊结果生成评级报告…`,
|
||||||
|
content: selectedDemand
|
||||||
|
? `${selectedDemand.company} AI成熟度评估结果\n\n评级:${selectedDemand.maturityLevel}级(评分 ${selectedDemand.maturityScore}/100)\n\n评估维度(基于浙江版制造型企业AI场景调研提纲10步评估):\n\n一、自动化和数字化基础\n• 数据采集系统:${selectedDemand.dataReadiness >= 70 ? '已部署MES/SCADA/IoT,设备数字化率较高' : '数据采集体系尚在建设中'}\n• 核心业务系统:${selectedDemand.dataReadiness >= 60 ? 'ERP/MES/WMS已部分覆盖,系统集成度待提升' : '核心系统部署率较低,建议优先补齐'}\n\n二、数据与知识积累\n• 数据资产积累:${selectedDemand.dataReadiness}% 就绪度,${selectedDemand.dataReadiness >= 70 ? '数据质量较好,已具备AI建模基础' : '数据质量与结构化程度需进一步提升'}\n• 工艺知识沉淀:${selectedDemand.technicalDesc ? '已有部分结构化知识,建议建设企业专属知识库' : '以纸质文档和老员工经验为主,亟需系统化沉淀'}\n\n三、团队与人才基础\n• IT/数字化团队:${selectedDemand.department}主导,建议配置专职AI人员\n• AI通识水平:建议组织温州AI+制造专项培训\n\n四、AI应用现状\n• AI项目经验:${selectedDemand.maturityScore >= 60 ? '已有探索性项目,需建立系统化AI能力底座' : '尚处起步阶段,建议从单点场景切入'}\n• 场景识别:已明确“${selectedDemand.scenario}”核心场景\n\n五、AI提升意愿\n• 领导力与预算:预算区间 ${selectedDemand.budget},${selectedDemand.maturityScore >= 50 ? '管理层AI转型目标较为明确' : '建议加强一把手重视与预算保障'}\n• 落地顾虑:建议引入AI翻译官进行需求精化,明确技术边界与验收KPI\n\n下一步:\n• 把该企业需求拖入中间“AI成熟度问诊”模块查看五维详细诊断\n• 或拖入“AI落地规划”模块查看分阶段路线图`
|
||||||
|
: `AI成熟度10步问诊\n\n请先从左侧选择或拖拽一家企业需求到中间“AI成熟度问诊”模块。\n\n问诊维度(基于浙江省制造型企业AI场景调研提纲):\n\n一、自动化和数字化基础\n• 步骤1:数据采集系统(MES/SCADA/IoT)部署情况\n• 步骤3:核心业务系统(ERP/MES/WMS/PLM)覆盖与集成\n\n二、数据与知识积累\n• 步骤2:生产/经营数据积累与质量\n• 步骤5:工艺知识沉淀形式与知识库建设\n\n三、团队与人才基础\n• 步骤4:专职IT/数字化团队与AI通识水平\n\n四、AI应用现状\n• 步骤6:已引入AI工具与项目经验\n• 步骤8:AI应用场景识别清晰度\n• 步骤10:AI算力与部署规划\n\n五、AI提升意愿\n• 步骤7:管理层目标与预算\n• 步骤9:推进顾虑与资源投入意愿`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (text.includes('方案') || text.includes('规划') || text.includes('落地')) {
|
||||||
|
return {
|
||||||
|
thinking: `分析温州企业需求场景:${selectedDemand?.scenario || '温州市AI+制造应用'}…\n匹配温州市“人工智能+制造”典型场景参考指引…\n评估数据就绪度与实施风险…\n生成分阶段落地路线图…`,
|
||||||
|
content: selectedDemand
|
||||||
|
? `温州市AI+制造落地规划建议 · ${selectedDemand.company}\n\n目标场景:${selectedDemand.scenario}\n所属产业:${selectedDemand.industry}|区域:${selectedDemand.region}\n主导部门:${selectedDemand.department}|期望周期:${selectedDemand.timeline}\n\n阶段一(1-3个月)· 数据摸底与现状诊断\n• 盘点现有数据资产,评估质量与完整性\n• 对照温州市典型场景参考指引定义KPI\n• 选定首批试点区域/产线\n• 引入AI翻译官完成需求精化与场景可行性分析\n\n阶段二(3-6个月)· 试点验证与效果追踪\n• 申请算力券+咨询券启动开发\n• 在试点范围验证AI效果,建立ROI跟踪机制\n• 方案迭代优化,确保可量化验收\n\n阶段三(6-12个月)· 规模推广与持续运营\n• 基于试点结果优化模型与部署架构\n• 逐步扩展至全部应用范围\n• 建立AI运维与持续优化机制\n• 复制成功经验至同产业其他企业\n\n预算参考:${selectedDemand.budget}(建议申报温州市AI+制造专项支持)\n\n提示:把该企业需求拖入中间“AI落地规划”模块查看完整路线图与阶段明细`
|
||||||
|
: `请先选择或拖拽企业需求到中间模块,我将为您生成专属AI落地规划方案。\n\n您也可以尝试快捷指令:\n• “AI成熟度问诊” — 先了解企业AI准备度\n• “匹配AI翻译官” — 找到合适的专家进行需求精化\n• “查询优惠券” — 了解可用的政策支持`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (text.includes('翻译官') || text.includes('匹配')) {
|
||||||
|
return {
|
||||||
|
thinking: `分析需求场景关键词…\n检索AI翻译官专长标签库…\n计算需求-翻译官匹配度…\n输出推荐排序…`,
|
||||||
|
content: selectedDemand
|
||||||
|
? `AI翻译官智能推荐 · ${selectedDemand.scenario}\n\n当前需求来自 ${selectedDemand.company}(${selectedDemand.region},${selectedDemand.industry}),场景属于温州市“人工智能+制造”重点方向。\n\n一、业务痛点转译\n• 一句话描述痛点:${selectedDemand.painPoint}\n• 核心期望价值:通过AI技术实现该环节效率提升、人工依赖降低、质量一致性提高\n• AI可能扮演的角色:作为“智能助手”与“数据分析专家”,对业务数据进行统一分析,沉淀经验与知识,提供优化建议与决策支持\n\n二、数据与技术基础\n• 数据就绪度:${selectedDemand.dataReadiness}%,${selectedDemand.dataReadiness >= 70 ? '已具备一定结构化数据基础,可支撑AI建模起步' : '建议先完成数据资产盘点与质量评估'}\n• 现有系统:${selectedDemand.technicalDesc ? '已有部分业务系统覆盖,系统集成度待进一步提升' : '需确认现有ERP/MES/WMS等系统部署情况与数据互通能力'}\n• 算力规划:建议评估本地部署与云端训练混合方案\n\n三、翻译官推荐\n建议优先匹配三类专家:\n1. 温州本地制造业甲方专家 — 熟悉${selectedDemand.industry}真实产线与业务语言,最懂甲方痛点\n2. 温州高校/研究院专家 — 适合算法验证、样本构建与技术路线评审\n3. 工业AI服务商专家 — 适合系统集成、交付实施与ROI测算\n\n四、验收标准与对接流程\n• MVP验收:在试点数据集上核心指标达标;完成2-3家合作企业现场试用\n• 最终验收:真实场景连续稳定运行1个月+,核心KPI提升≥30%\n• 对接流程:①企业提交场景痛点与数据样本 ②AI翻译官输出需求说明与技术边界 ③进入落地规划与服务商对接\n\n点击右侧AI翻译官卡片发起对接申请`
|
||||||
|
: `请先选择或拖拽企业需求,我将为您推荐最匹配的AI翻译官。\n\nAI翻译官的职责:\n• 解析制造企业AI需求和场景痛点,量化业务损失与“理想状态”目标\n• 评估数据可用性、现有IT设施与算力资源\n• 用业务语言描述问题和解法,明确技术边界、输入数据和验收KPI\n• 定义MVP验收标准与最终项目验收标准\n\n来源:温州市人工智能局专家库、温州大学智能制造研究团队、乐清智能电气产业顾问组等`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (text.includes('优惠券') || text.includes('券')) {
|
||||||
|
return {
|
||||||
|
thinking: `查询温州市人工智能局AI惠企政策库…\n匹配企业区域、产业类型与需求场景…\n过滤可用优惠券…\n计算可获得支持方向…`,
|
||||||
|
content: `可领取AI优惠券(${COUPONS.length}种)\n\n立即可用:\n• 算力券 — 支持模型训练、边缘推理和云端部署,覆盖工业视觉、预测维护等场景\n• AI翻译官咨询券 — 温州企业首次需求解析与成熟度诊断,8课时1对1服务\n• 工具券 — 支持工业大模型、知识库、视觉检测等试用,3个月免费API调用\n\n申请制:\n• 语料券 — 工业图像标注、工艺资料结构化、售后知识库整理\n• 应用券 — 需完成AI落地规划认定后进入试点支持,专项补贴最高¥10000\n\n发放机构:温州市人工智能局\n适用对象:温州市AI成熟度L1-L3制造企业\n\n提示:把企业需求拖入中间“优惠券领取”模块可查看全部详情并匹配申领`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (text.includes('政策') || text.includes('补贴')) {
|
||||||
|
return {
|
||||||
|
thinking: `检索温州市“AI+制造”扶持政策库…\n匹配区县、产业链和典型场景…\n汇总可申请支持路径…`,
|
||||||
|
content: `温州市“AI+制造”政策解读\n\n支持方向:\n• 围绕智能电气、鞋业服装、汽车零部件、工业泵阀、新能源、智能装备等温州重点产业开展AI试点\n• 支持AI质量检测、AI计划排产、设备故障诊断、产品AI设计、智能经营决策等典型场景\n• 支持温州企业建设行业知识库、场景智能体、数据中台和AI应用示范线\n\n申报路径:\n1. 在本平台完成AI成熟度问诊评级\n2. 由AI翻译官输出业务语言版需求说明和技术路线建议\n3. 提交AI落地规划与试点KPI\n4. 通过温州市人工智能局认定后领取对应优惠券或试点支持\n\n政策来源:\n• 2026年第一批人工智能赋能新型工业化需求清单\n• 温州市“人工智能+制造”典型场景参考指引(试行)\n• 温州市AI+制造专项补贴与优惠券管理办法`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 通用回复
|
||||||
|
const name = selectedTranslator ? `【@${selectedTranslator.name}】` : '';
|
||||||
|
return {
|
||||||
|
thinking: `${name ? `调用${name}专家知识库…\n` : ''}分析问题关键信息…\n结合制造业AI落地经验…\n生成个性化回答…`,
|
||||||
|
content: `${name ? `${name} ` : ''}收到您的问题:"${text}"\n\n基于温州市“AI+制造”智创空间分析:\n\n${selectedDemand ? `当前聚焦企业:${selectedDemand.company}\n区域:${selectedDemand.region}\n产业:${selectedDemand.industry}\n场景:${selectedDemand.scenario}\n成熟度:${selectedDemand.maturityLevel}(评分 ${selectedDemand.maturityScore}/100)\n数据就绪度:${selectedDemand.dataReadiness}%\n预算区间:${selectedDemand.budget}\n期望周期:${selectedDemand.timeline}\n\n` : ''}建议您使用快捷指令进行精准查询:\n• 输入“AI成熟度问诊” — 了解温州企业AI准备度\n• 输入“推荐落地方案” — 获取定制化路线图\n• 输入“匹配AI翻译官” — 找到最合适的专家\n• 输入“查询优惠券” — 了解可领取的政策支持\n• 输入“政策解读” — 查看温州市AI+制造扶持政策\n\n或者直接把左侧企业AI需求拖入中间 Agentic Space 的四个模块,即可生成对应内容。`,
|
||||||
|
};
|
||||||
|
}, [selectedDemand, selectedTranslator]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback((text: string) => {
|
||||||
|
if (!text.trim() || isTyping) return;
|
||||||
|
setTab('chat');
|
||||||
|
setMessages(prev => [...prev, { id: Date.now(), role: 'user', thinking: '', content: text, thinkingDone: true, done: true, time: getTimeStr() }]);
|
||||||
|
setInput('');
|
||||||
|
setIsTyping(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const { thinking, content } = buildAIReply(text);
|
||||||
|
const aiMsgId = Date.now() + 1;
|
||||||
|
setMessages(prev => [...prev, { id: aiMsgId, role: 'ai', thinking: '', content: '', thinkingDone: false, done: false, time: getTimeStr() }]);
|
||||||
|
streamText(aiMsgId, 'thinking', thinking, 55, () => {
|
||||||
|
setTimeout(() => { streamText(aiMsgId, 'content', content, 40, () => setIsTyping(false)); }, 600);
|
||||||
|
});
|
||||||
|
}, 450);
|
||||||
|
}, [isTyping, buildAIReply, streamText]);
|
||||||
|
|
||||||
|
const maturityScore = Object.values(maturityAnswers).filter(v => v === true).length;
|
||||||
|
const maturityLevel = maturityScore <= 2 ? 'L1' : maturityScore <= 4 ? 'L2' : maturityScore <= 6 ? 'L3' : maturityScore <= 8 ? 'L4' : 'L5';
|
||||||
|
const currentStep = MATURITY_STEPS[maturityStep];
|
||||||
|
const currentCategory = currentStep?.category || '';
|
||||||
|
|
||||||
|
const chatHeight = expanded ? 380 : 90;
|
||||||
|
const ARC_H = 60;
|
||||||
|
|
||||||
|
const TABS: { key: TabType; label: string; icon: TechIconName }[] = [
|
||||||
|
{ key: 'chat', label: '智能问答', icon: 'chat' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-up" style={{ flexShrink: 0, paddingLeft: '295px', paddingRight: '310px', paddingBottom: 0, position: 'relative', overflow: 'visible' }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{/* 弧形顶部 */}
|
||||||
|
<svg viewBox="0 0 1000 110" preserveAspectRatio="none" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: ARC_H, pointerEvents: 'none', zIndex: 1, display: 'block', overflow: 'visible' }}>
|
||||||
|
<path d="M 0,110 C 150,-40 850,-40 1000,110 L 1000,110 L 0,110 Z" fill="rgba(0,15,50,0.88)" />
|
||||||
|
<path d="M 0,110 C 150,-40 850,-40 1000,110" fill="none" stroke="rgba(0,212,255,0.5)" strokeWidth="1.2" />
|
||||||
|
<circle cx="0" cy="110" r="5" fill="rgba(0,212,255,0.8)" />
|
||||||
|
<circle cx="1000" cy="110" r="5" fill="rgba(0,212,255,0.8)" />
|
||||||
|
<circle cx="500" cy="2" r="3" fill="rgba(0,212,255,0.5)" />
|
||||||
|
<circle cx="200" cy="35" r="2" fill="rgba(0,212,255,0.3)" />
|
||||||
|
<circle cx="800" cy="35" r="2" fill="rgba(0,212,255,0.3)" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{ position: 'absolute', top: ARC_H - 1, left: 0, right: 0, bottom: 0, zIndex: 0, background: 'linear-gradient(to bottom, rgba(0,15,50,0.88), rgba(0,8,28,0.95))', backdropFilter: 'blur(18px)', WebkitBackdropFilter: 'blur(18px)' }} />
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', zIndex: 2, paddingTop: ARC_H }}>
|
||||||
|
|
||||||
|
{/* Tab 切换栏 */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, padding: '6px 20px 0', borderBottom: '1px solid rgba(0,212,255,0.1)' }}>
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button key={t.key} onClick={() => setTab(t.key)} style={{
|
||||||
|
padding: '6px 16px', borderRadius: '8px 8px 0 0', fontSize: 16, fontWeight: 700, cursor: 'pointer',
|
||||||
|
background: tab === t.key ? 'rgba(0,212,255,0.1)' : 'transparent',
|
||||||
|
color: tab === t.key ? '#00d4ff' : 'rgba(255,255,255,0.5)',
|
||||||
|
borderBottom: tab === t.key ? '2px solid #00d4ff' : '2px solid transparent',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<TechIcon name={t.icon} color={tab === t.key ? '#00d4ff' : 'rgba(255,255,255,0.5)'} size={18} />
|
||||||
|
{t.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 6 }}>
|
||||||
|
{selectedDemand && (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: 'rgba(0,212,255,0.08)', border: '1px solid rgba(0,212,255,0.2)', color: '#00d4ff', fontSize: 12 }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}><TechIcon name="pin" color="#00d4ff" size={13} />{selectedDemand.company}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedTranslator && (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: 'rgba(123,97,255,0.08)', border: '1px solid rgba(123,97,255,0.2)', color: '#7b61ff', fontSize: 12 }}>
|
||||||
|
@{selectedTranslator.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setExpanded(v => !v)} style={{ padding: '3px 10px', borderRadius: 7, border: '1px solid rgba(255,255,255,0.08)', background: 'transparent', color: 'rgba(255,255,255,0.4)', fontSize: 12, cursor: 'pointer' }}>
|
||||||
|
{expanded ? <><TechIcon name="collapse" color="rgba(255,255,255,0.45)" size={13} /> 收起</> : <><TechIcon name="expand" color="rgba(255,255,255,0.45)" size={13} /> 展开</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── TAB: 智能问答 ─── */}
|
||||||
|
{tab === 'chat' && (
|
||||||
|
<>
|
||||||
|
<div ref={scrollRef} className="space-scroll" style={{ height: chatHeight, overflowY: 'auto', padding: '8px 24px 4px', transition: 'height 0.25s ease' }}>
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} style={{ display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start', marginBottom: 6 }}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<span style={{ width: 28, height: 28, borderRadius: '50%', background: 'linear-gradient(135deg,rgba(0,30,80,0.9),rgba(0,60,120,0.85))', border: '1px solid rgba(0,212,255,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginRight: 7, marginTop: 2 }}><TechIcon name="bot" color="#00d4ff" size={16} /></span>
|
||||||
|
)}
|
||||||
|
<div style={{ maxWidth: '82%' }}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<>
|
||||||
|
{msg.thinking && (
|
||||||
|
<div style={{ padding: '4px 10px', borderRadius: '9px 9px 9px 2px', background: 'rgba(0,40,80,0.35)', border: '1px solid rgba(0,212,255,0.07)', marginBottom: msg.thinkingDone && msg.content ? 5 : 0 }}>
|
||||||
|
<span style={{ fontStyle: 'italic', color: 'rgba(255,255,255,0.6)', fontSize: 14, lineHeight: 1.65, whiteSpace: 'pre-line', display: 'block' }}>
|
||||||
|
{!msg.thinkingDone && <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.5)', marginRight: 5, verticalAlign: 'middle', animation: 'space-pulse 0.8s ease-in-out infinite' }} />}
|
||||||
|
{msg.thinking}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg.thinkingDone && msg.content && (
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '2px 12px 12px 12px', background: 'rgba(0,20,60,0.55)', border: '1px solid rgba(0,212,255,0.12)', color: 'rgba(255,255,255,0.92)', fontSize: 15, lineHeight: 1.65, whiteSpace: 'pre-line' }}>
|
||||||
|
{msg.content}
|
||||||
|
{!msg.done && <span style={{ display: 'inline-block', width: 2, height: '1em', background: '#00d4ff', marginLeft: 2, verticalAlign: 'text-bottom', animation: 'space-pulse 0.6s ease-in-out infinite' }} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '12px 12px 2px 12px', background: 'rgba(0,122,255,0.22)', border: '1px solid rgba(0,122,255,0.3)', color: 'rgba(255,255,255,0.95)', fontSize: 15, lineHeight: 1.65, whiteSpace: 'pre-line' }}>{msg.content}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.3)', fontSize: 12, marginTop: 2, textAlign: msg.role === 'user' ? 'right' : 'left' }}>{msg.time}</div>
|
||||||
|
</div>
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<span style={{ width: 26, height: 26, borderRadius: '50%', background: 'rgba(0,122,255,0.2)', border: '1px solid rgba(0,122,255,0.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginLeft: 7, marginTop: 2 }}><TechIcon name="user" color="#5aabff" size={14} /></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isTyping && messages[messages.length - 1]?.role !== 'ai' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||||
|
<span style={{ width: 28, height: 28, borderRadius: '50%', background: 'linear-gradient(135deg,rgba(0,30,80,0.9),rgba(0,60,120,0.85))', border: '1px solid rgba(0,212,255,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><TechIcon name="bot" color="#00d4ff" size={16} /></span>
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '12px 12px 12px 2px', background: 'rgba(0,20,60,0.55)', border: '1px solid rgba(0,212,255,0.12)', display: 'flex', gap: 5, alignItems: 'center' }}>
|
||||||
|
{[0,1,2].map(i => <span key={i} style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#00d4ff', animation: `space-pulse 1s ${i * 0.2}s ease-in-out infinite` }} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<div style={{ padding: '4px 24px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 7, flexWrap: 'wrap' }}>
|
||||||
|
{QUICK_CMDS.map(cmd => (
|
||||||
|
<button key={cmd} onClick={() => sendMessage(cmd)} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 18, border: '1px solid rgba(0,212,255,0.2)', background: 'rgba(0,212,255,0.06)', color: 'rgba(255,255,255,0.8)', fontSize: 12, cursor: 'pointer', transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background='rgba(0,212,255,0.14)'; e.currentTarget.style.borderColor='rgba(0,212,255,0.4)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background='rgba(0,212,255,0.06)'; e.currentTarget.style.borderColor='rgba(0,212,255,0.2)'; }}
|
||||||
|
>{cmd}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => { if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(input); } }}
|
||||||
|
placeholder={selectedDemand ? `向AI助手提问关于 ${selectedDemand.company} 的AI需求…` : '输入问题,如:帮我分析这家企业的AI落地优先级…'}
|
||||||
|
style={{ flex: 1, padding: '9px 16px', borderRadius: 22, background: 'rgba(0,30,70,0.6)', border: '1px solid rgba(0,212,255,0.2)', color: 'rgba(255,255,255,0.92)', fontSize: 13, outline: 'none', transition: 'border-color 0.15s ease' }}
|
||||||
|
onFocus={e => (e.target.style.borderColor='rgba(0,212,255,0.5)')}
|
||||||
|
onBlur={e => (e.target.style.borderColor='rgba(0,212,255,0.2)')}
|
||||||
|
/>
|
||||||
|
<button onClick={() => sendMessage(input)} disabled={!input.trim() || isTyping} style={{
|
||||||
|
width: 40, height: 40, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: input.trim() && !isTyping ? 'linear-gradient(135deg, #0066cc, #00aaff)' : 'rgba(0,50,100,0.3)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.3)', color: input.trim() && !isTyping ? '#fff' : 'rgba(255,255,255,0.3)',
|
||||||
|
fontSize: 17, cursor: input.trim() && !isTyping ? 'pointer' : 'not-allowed',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: input.trim() && !isTyping ? '0 0 12px rgba(0,170,255,0.4)' : 'none',
|
||||||
|
}}><TechIcon name="deploy" color={input.trim() && !isTyping ? '#fff' : 'rgba(255,255,255,0.3)'} size={17} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── TAB: 成熟度问诊 ─── */}
|
||||||
|
{tab === 'maturity' && (
|
||||||
|
<div style={{ padding: '12px 24px 16px', minHeight: 160 }}>
|
||||||
|
{!maturityDone ? (
|
||||||
|
<>
|
||||||
|
<div style={{ color: '#a8d8ff', fontSize: 14, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}><TechIcon name="maturity" color="#00d4ff" size={18} />AI成熟度10步评估 · {currentCategory}</span>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>第 {maturityStep + 1} / {MATURITY_STEPS.length} 步</span>
|
||||||
|
</div>
|
||||||
|
{maturityStep < MATURITY_STEPS.length ? (
|
||||||
|
<div style={{ padding: '14px 18px', borderRadius: 12, background: 'rgba(0,212,255,0.06)', border: '1px solid rgba(0,212,255,0.2)' }}>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.92)', fontSize: 15, margin: '0 0 6px', lineHeight: 1.6, fontWeight: 600 }}>
|
||||||
|
Q{maturityStep + 1}. {MATURITY_STEPS[maturityStep].q}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, margin: '0 0 14px', lineHeight: 1.55 }}>
|
||||||
|
{MATURITY_STEPS[maturityStep].sub}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
{[
|
||||||
|
{ label: '已具备/较好', icon: 'check' as TechIconName, value: true, color: '#00e599' },
|
||||||
|
{ label: '尚未具备/较弱', icon: 'close' as TechIconName, value: false, color: '#ff6b6b' },
|
||||||
|
{ label: '部分具备', icon: 'target' as TechIconName, value: 'partial' as string | boolean, color: '#ffb340' },
|
||||||
|
].map(opt => (
|
||||||
|
<button key={String(opt.value)} onClick={() => {
|
||||||
|
setMaturityAnswers(prev => ({ ...prev, [MATURITY_STEPS[maturityStep].key]: opt.value === 'partial' ? 'partial' : opt.value }));
|
||||||
|
if (maturityStep + 1 >= MATURITY_STEPS.length) setMaturityDone(true);
|
||||||
|
else setMaturityStep(s => s + 1);
|
||||||
|
}} style={{
|
||||||
|
flex: 1, padding: '10px 0', borderRadius: 8, fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||||
|
background: `${opt.color}18`, border: `1px solid ${opt.color}40`, color: opt.color, transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background=`${opt.color}28`; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background=`${opt.color}18`; }}
|
||||||
|
><span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 7 }}><TechIcon name={opt.icon} color={opt.color} size={15} />{opt.label}</span></button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* 进度 */}
|
||||||
|
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(0,212,255,0.1)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${(maturityStep / MATURITY_STEPS.length) * 100}%`, height: '100%', background: 'linear-gradient(to right, #00d4ff, #00e599)', borderRadius: 2, transition: 'width 0.3s ease' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>{maturityStep}/{MATURITY_STEPS.length}</span>
|
||||||
|
{maturityStep > 0 && <button onClick={() => { setMaturityStep(s => s - 1); }} style={{ padding: '3px 8px', borderRadius: 6, background: 'transparent', border: '1px solid rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.5)', fontSize: 11, cursor: 'pointer' }}>上一步</button>}
|
||||||
|
<button onClick={() => { setMaturityStep(0); setMaturityAnswers({}); setMaturityDone(false); }} style={{ padding: '3px 8px', borderRadius: 6, background: 'transparent', border: '1px solid rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.5)', fontSize: 11, cursor: 'pointer' }}>重置</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ color: '#00e599', fontSize: 16, fontWeight: 700, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 7 }}><TechIcon name="check" color="#00e599" size={18} />10步评估完成!</div>
|
||||||
|
<div style={{ padding: '12px 14px', borderRadius: 10, background: 'rgba(0,229,153,0.06)', border: '1px solid rgba(0,229,153,0.2)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: '#00e599' }}>{maturityLevel}</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 14, fontWeight: 600 }}>AI成熟度等级</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>综合得分 {maturityScore * 10}/100</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 分类小结 */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 10 }}>
|
||||||
|
{['自动化和数字化基础', '数据与知识积累', '团队与人才基础', 'AI应用现状', 'AI提升意愿'].map(cat => {
|
||||||
|
const catKeys = MATURITY_STEPS.filter(s => s.category === cat).map(s => s.key);
|
||||||
|
const catYes = catKeys.filter(k => maturityAnswers[k] === true).length;
|
||||||
|
const catTotal = catKeys.length;
|
||||||
|
const catPct = Math.round((catYes / catTotal) * 100);
|
||||||
|
return (
|
||||||
|
<div key={cat} style={{ padding: '6px 10px', borderRadius: 8, background: 'rgba(0,212,255,0.06)', border: '1px solid rgba(0,212,255,0.12)' }}>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 11, marginBottom: 4 }}>{cat}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.08)' }}>
|
||||||
|
<div style={{ width: `${catPct}%`, height: '100%', borderRadius: 2, background: catPct >= 70 ? '#00e599' : catPct >= 40 ? '#ffb340' : '#ff6b6b' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: catPct >= 70 ? '#00e599' : catPct >= 40 ? '#ffb340' : '#ff6b6b', fontSize: 11, fontWeight: 700 }}>{catPct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.8)', fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||||
|
{maturityLevel === 'L1' && '处于AI启蒙期,建议从基础数字化建设入手:先建立数据采集能力、部署核心业务系统,再逐步积累工艺知识。'}
|
||||||
|
{maturityLevel === 'L2' && '具备探索基础,建议聚焦1-2个清晰场景进行AI小规模试点验证,同时补强数据治理和团队AI技能。'}
|
||||||
|
{maturityLevel === 'L3' && '已有一定AI基础,适合推进场景规模化复制和AI能力体系建设,重点加强数据互通与知识沉淀。'}
|
||||||
|
{maturityLevel === 'L4' && 'AI能力较成熟,可考虑构建自主AI开发能力和智能决策支持系统,探索算力基础设施优化。'}
|
||||||
|
{maturityLevel === 'L5' && '行业AI领先水平,建议向AI驱动业务创新方向演进,探索AI商业化与行业赋能输出。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setMaturityStep(0); setMaturityAnswers({}); setMaturityDone(false); }} style={{ marginTop: 8, padding: '6px 16px', borderRadius: 8, background: 'rgba(0,212,255,0.08)', border: '1px solid rgba(0,212,255,0.25)', color: '#00d4ff', fontSize: 12, cursor: 'pointer' }}>重新评估</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── TAB: AI落地规划 ─── */}
|
||||||
|
{tab === 'roadmap' && (
|
||||||
|
<div style={{ padding: '12px 24px 16px' }}>
|
||||||
|
<div style={{ color: '#a8d8ff', fontSize: 13, fontWeight: 600, marginBottom: 10 }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}><TechIcon name="roadmap" color="#00d4ff" size={17} />{selectedDemand ? `${selectedDemand.company} · AI落地路线图` : '通用AI落地规划参考'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, overflowX: 'auto' }} className="space-scroll">
|
||||||
|
{[
|
||||||
|
{ phase: '阶段一', title: '现状诊断', duration: '1-2个月', color: '#ffb340', items: ['AI成熟度评估', '数据资产盘点', '场景识别优先级'], icon: 'analytics' as TechIconName },
|
||||||
|
{ phase: '阶段二', title: '需求精化', duration: '1-3个月', color: '#00d4ff', items: ['AI翻译官对接', '场景可行性分析', '技术方案评审'], icon: 'target' as TechIconName },
|
||||||
|
{ phase: '阶段三', title: '试点验证', duration: '3-6个月', color: '#7b61ff', items: ['小规模试点部署', 'ROI效果追踪', '方案迭代优化'], icon: 'lab' as TechIconName },
|
||||||
|
{ phase: '阶段四', title: '规模推广', duration: '6-12个月', color: '#00e599', items: ['扩大应用范围', '建立AI运维体系', '复制成功经验'], icon: 'deploy' as TechIconName },
|
||||||
|
].map(stage => (
|
||||||
|
<div key={stage.phase} style={{ minWidth: 160, padding: '10px 12px', borderRadius: 10, background: `${stage.color}08`, border: `1px solid ${stage.color}25`, flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||||
|
<TechIcon name={stage.icon} color={stage.color} size={20} />
|
||||||
|
<div>
|
||||||
|
<div style={{ color: stage.color, fontSize: 12, fontWeight: 700 }}>{stage.phase}</div>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 13, fontWeight: 600 }}>{stage.title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.45)', fontSize: 11, marginBottom: 8 }}>周期 {stage.duration}</div>
|
||||||
|
{stage.items.map(item => (
|
||||||
|
<div key={item} style={{ color: 'rgba(255,255,255,0.75)', fontSize: 12, lineHeight: 1.6 }}>· {item}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── TAB: AI优惠券 ─── */}
|
||||||
|
{tab === 'coupons' && (
|
||||||
|
<div style={{ padding: '10px 24px 14px' }}>
|
||||||
|
<div style={{ color: '#a8d8ff', fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}><TechIcon name="coupon" color="#00d4ff" size={17} />温州市AI+制造优惠券({COUPONS.length}种可用)</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, overflowX: 'auto' }} className="space-scroll">
|
||||||
|
{COUPONS.map(coupon => {
|
||||||
|
const cfg = couponTypeConfig[coupon.type];
|
||||||
|
const isClaimed = claimedCoupons.has(coupon.id);
|
||||||
|
return (
|
||||||
|
<div key={coupon.id} style={{
|
||||||
|
minWidth: 170, padding: '10px 12px', borderRadius: 10, flexShrink: 0,
|
||||||
|
background: `${coupon.color}08`, border: `1px solid ${coupon.color}${isClaimed ? '60' : '25'}`,
|
||||||
|
opacity: isClaimed ? 0.7 : 1, transition: 'all 0.18s ease',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||||
|
<TechIcon name={(coupon.icon as TechIconName) || 'coupon'} color={coupon.color} size={22} />
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 4, background: cfg.bg, color: cfg.color, fontSize: 10, fontWeight: 700 }}>{cfg.label}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 13, fontWeight: 700 }}>{coupon.title}</div>
|
||||||
|
<div style={{ color: coupon.color, fontSize: 18, fontWeight: 800, margin: '4px 0 2px' }}>{coupon.value}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11, lineHeight: 1.4, marginBottom: 6 }}>{coupon.condition}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: 10, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 5 }}><TechIcon name="calendar" color="rgba(255,255,255,0.4)" size={12} />{coupon.expiry} · 余{coupon.remaining}张</div>
|
||||||
|
<button onClick={() => setClaimedCoupons(prev => { const s = new Set(prev); isClaimed ? s.delete(coupon.id) : s.add(coupon.id); return s; })} style={{
|
||||||
|
width: '100%', padding: '5px 0', borderRadius: 6, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
background: isClaimed ? `${coupon.color}18` : `${coupon.color}22`,
|
||||||
|
border: `1px solid ${coupon.color}40`, color: isClaimed ? coupon.color : '#fff',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}>
|
||||||
|
{isClaimed ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}><TechIcon name="check" color={coupon.color} size={13} />已领取</span> : '立即领取'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
import { useEffect, useRef, useState, type DragEvent } from 'react';
|
||||||
|
import { DEMANDS, type AIDemand, maturityConfig, statusConfig } from '@/shared/data/demands';
|
||||||
|
import { COUPONS, couponTypeConfig } from '@/shared/data/coupons';
|
||||||
|
import { orgTypeConfig, type Translator } from '@/shared/data/translators';
|
||||||
|
import TechIcon, { type TechIconName } from '@/shared/components/TechIcons';
|
||||||
|
|
||||||
|
interface CenterDemandDetailProps {
|
||||||
|
demand: AIDemand | null;
|
||||||
|
translator: Translator | null;
|
||||||
|
resetKey?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentModule = 'maturity' | 'translator' | 'roadmap' | 'coupon';
|
||||||
|
|
||||||
|
const industryColors: Record<string, string> = {
|
||||||
|
'智能电气': '#00d4ff',
|
||||||
|
'鞋业服装': '#ff6b9d',
|
||||||
|
'汽车零部件': '#ff8c42',
|
||||||
|
'工业泵阀': '#7b61ff',
|
||||||
|
'智能装备': '#4fc3f7',
|
||||||
|
'新能源': '#00e599',
|
||||||
|
'新材料': '#ffb340',
|
||||||
|
'智能物联': '#35d0ba',
|
||||||
|
'眼镜': '#9bdcff',
|
||||||
|
'食品农产品': '#8bd450',
|
||||||
|
'低空经济': '#b58cff',
|
||||||
|
};
|
||||||
|
|
||||||
|
const capabilityLabels = ['数据就绪', '系统集成', 'AI经验', '业务牵引', '场景清晰'];
|
||||||
|
|
||||||
|
const capabilityScoreMap: Record<string, number[]> = {
|
||||||
|
WZ001: [78, 82, 18, 76, 14],
|
||||||
|
WZ002: [82, 80, 22, 78, 16],
|
||||||
|
WZ003: [66, 18, 12, 72, 80],
|
||||||
|
WZ004: [63, 71, 15, 24, 84],
|
||||||
|
WZ005: [70, 16, 14, 82, 86],
|
||||||
|
WZ006: [52, 18, 12, 74, 79],
|
||||||
|
WZ007: [62, 14, 68, 80, 18],
|
||||||
|
WZ008: [48, 16, 74, 20, 82],
|
||||||
|
WZ009: [58, 24, 14, 76, 81],
|
||||||
|
WZ010: [50, 12, 70, 78, 18],
|
||||||
|
WZ011: [60, 18, 82, 14, 75],
|
||||||
|
WZ012: [61, 20, 78, 15, 72],
|
||||||
|
WZ013: [65, 17, 80, 82, 12],
|
||||||
|
WZ014: [58, 72, 15, 18, 80],
|
||||||
|
WZ015: [45, 12, 18, 77, 72],
|
||||||
|
WZ016: [68, 18, 13, 80, 84],
|
||||||
|
WZ017: [36, 14, 72, 18, 82],
|
||||||
|
WZ018: [40, 16, 18, 75, 82],
|
||||||
|
WZ019: [55, 78, 18, 84, 13],
|
||||||
|
WZ020: [49, 14, 76, 20, 82],
|
||||||
|
WZ021: [90, 86, 82, 78, 18],
|
||||||
|
WZ022: [64, 22, 75, 80, 16],
|
||||||
|
WZ023: [78, 82, 17, 15, 86],
|
||||||
|
WZ024: [72, 78, 18, 84, 15],
|
||||||
|
WZ025: [60, 15, 80, 18, 84],
|
||||||
|
WZ026: [50, 14, 16, 76, 82],
|
||||||
|
WZ027: [76, 84, 18, 80, 15],
|
||||||
|
WZ028: [75, 82, 78, 18, 14],
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleCards: { key: AgentModule; label: string; icon: TechIconName; color: string; desc: string }[] = [
|
||||||
|
{ key: 'maturity', label: 'AI成熟度问诊', icon: 'maturity', color: '#00d4ff', desc: '投放企业需求,生成五维成熟度诊断' },
|
||||||
|
{ key: 'translator', label: 'AI翻译官解析', icon: 'translator', color: '#7b61ff', desc: '投放企业需求,转译业务痛点与技术边界' },
|
||||||
|
{ key: 'roadmap', label: 'AI落地规划', icon: 'roadmap', color: '#00e599', desc: '投放企业需求,生成分阶段实施路线' },
|
||||||
|
{ key: 'coupon', label: '优惠券领取', icon: 'coupon', color: '#ff8c42', desc: '投放企业需求,匹配算力/语料/咨询等券' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function RadarChart({ scores, color }: { scores: number[]; color: string }) {
|
||||||
|
const cx = 60, cy = 60, r = 48;
|
||||||
|
const points = scores.map((v, i) => {
|
||||||
|
const angle = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const pct = Math.max(0.08, Math.min(1, v / 100));
|
||||||
|
return {
|
||||||
|
x: cx + r * pct * Math.cos(angle),
|
||||||
|
y: cy + r * pct * Math.sin(angle),
|
||||||
|
labelX: cx + (r + 18) * Math.cos(angle),
|
||||||
|
labelY: cy + (r + 18) * Math.sin(angle),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const polyStr = points.map(p => `${p.x},${p.y}`).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={150} height={140} viewBox="0 0 150 140">
|
||||||
|
<g transform="translate(15 5)">
|
||||||
|
{[0.25, 0.5, 0.75, 1].map(frac => {
|
||||||
|
const ps = Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
return `${cx + r * frac * Math.cos(a)},${cy + r * frac * Math.sin(a)}`;
|
||||||
|
}).join(' ');
|
||||||
|
return <polygon key={frac} points={ps} fill="none" stroke={`rgba(0,212,255,${frac * 0.1 + 0.06})`} strokeWidth="0.8" />;
|
||||||
|
})}
|
||||||
|
{Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
return <line key={i} x1={cx} y1={cy} x2={cx + r * Math.cos(a)} y2={cy + r * Math.sin(a)} stroke="rgba(0,212,255,0.12)" strokeWidth="0.8" />;
|
||||||
|
})}
|
||||||
|
<polygon points={polyStr} fill={`${color}20`} stroke={color} strokeWidth="2" style={{ filter: `drop-shadow(0 0 5px ${color}80)` }} />
|
||||||
|
{points.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r="3" fill={color} />)}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<text key={capabilityLabels[i]} x={p.labelX} y={p.labelY} textAnchor="middle" fill="rgba(255,255,255,0.48)" fontSize="8">
|
||||||
|
{scores[i]}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaturityGauge({ score, color }: { score: number; color: string }) {
|
||||||
|
const R = 38, cx = 50, cy = 52;
|
||||||
|
const startAngle = -220 * Math.PI / 180;
|
||||||
|
const endAngle = 40 * Math.PI / 180;
|
||||||
|
const totalAngle = endAngle - startAngle;
|
||||||
|
const progressAngle = startAngle + totalAngle * (score / 100);
|
||||||
|
|
||||||
|
const arcPath = (fromA: number, toA: number, r: number) => {
|
||||||
|
const x1 = cx + r * Math.cos(fromA), y1 = cy + r * Math.sin(fromA);
|
||||||
|
const x2 = cx + r * Math.cos(toA), y2 = cy + r * Math.sin(toA);
|
||||||
|
const large = (toA - fromA) > Math.PI ? 1 : 0;
|
||||||
|
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={100} height={70} viewBox="0 0 100 70">
|
||||||
|
<path d={arcPath(startAngle, endAngle, R)} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="6" strokeLinecap="round" />
|
||||||
|
<path d={arcPath(startAngle, progressAngle, R)} fill="none" stroke={color} strokeWidth="6" strokeLinecap="round" style={{ filter: `drop-shadow(0 0 4px ${color}80)` }} />
|
||||||
|
<text x={cx} y={cy + 4} textAnchor="middle" fill={color} fontSize="16" fontWeight="700">{score}</text>
|
||||||
|
<text x={cx} y={cy + 15} textAnchor="middle" fill="rgba(255,255,255,0.5)" fontSize="8">成熟度评分</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleCard({ card, active, inactive, compact, onDrop }: { card: typeof moduleCards[number]; active?: boolean; inactive?: boolean; compact?: boolean; onDrop: (module: AgentModule, event: DragEvent<HTMLDivElement>) => void }) {
|
||||||
|
const labelColor = inactive ? 'rgba(255,255,255,0.28)' : '#e8f4ff';
|
||||||
|
const descColor = inactive ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.58)';
|
||||||
|
const iconColor = inactive ? 'rgba(255,255,255,0.2)' : card.color;
|
||||||
|
const iconBg = inactive ? 'rgba(255,255,255,0.04)' : `${card.color}12`;
|
||||||
|
const iconBorder = inactive ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${card.color}45`;
|
||||||
|
const cardBg = active ? `${card.color}16` : inactive ? 'rgba(255,255,255,0.02)' : `${card.color}0d`;
|
||||||
|
const cardBorder = active ? `1px solid ${card.color}70` : inactive ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${card.color}38`;
|
||||||
|
const cardShadow = active ? `0 0 26px ${card.color}18` : inactive ? 'none' : `inset 0 0 24px ${card.color}08`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={event => { event.preventDefault(); event.currentTarget.style.background = `${card.color}18`; }}
|
||||||
|
onDragLeave={event => { event.currentTarget.style.background = cardBg; }}
|
||||||
|
onDrop={event => onDrop(card.key, event)}
|
||||||
|
style={{
|
||||||
|
minHeight: compact ? 92 : 150,
|
||||||
|
padding: compact ? '14px 16px' : '24px 26px',
|
||||||
|
borderRadius: compact ? 14 : 18,
|
||||||
|
background: cardBg,
|
||||||
|
border: cardBorder,
|
||||||
|
boxShadow: cardShadow,
|
||||||
|
color: 'rgba(255,255,255,0.86)',
|
||||||
|
animation: 'card-in 0.35s both',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: compact ? 'row' : 'column',
|
||||||
|
alignItems: compact ? 'center' : 'flex-start',
|
||||||
|
gap: compact ? 12 : 14,
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: compact ? 44 : 68,
|
||||||
|
height: compact ? 44 : 68,
|
||||||
|
borderRadius: compact ? 12 : 18,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: iconBg, border: iconBorder,
|
||||||
|
boxShadow: inactive ? 'none' : `0 0 18px ${card.color}20`,
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
}}>
|
||||||
|
<TechIcon name={card.icon} color={iconColor} size={compact ? 30 : 46} />
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ color: labelColor, fontSize: compact ? 14 : 18, fontWeight: 850, letterSpacing: '0.02em', transition: 'color 0.25s ease' }}>{card.label}</div>
|
||||||
|
<div style={{ color: descColor, fontSize: compact ? 12 : 14, lineHeight: 1.6, marginTop: compact ? 3 : 8, transition: 'color 0.25s ease' }}>{card.desc}</div>
|
||||||
|
{!compact && !inactive && <div style={{ color: card.color, fontSize: 12, fontWeight: 700, marginTop: 12 }}>拖入左侧企业AI需求卡片启动</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProcessingScreen({ module, demand }: { module: AgentModule; demand: AIDemand }) {
|
||||||
|
const card = moduleCards.find(c => c.key === module)!;
|
||||||
|
const steps = [
|
||||||
|
'解析企业 AI 需求场景…',
|
||||||
|
'调取数据资产与基础评估…',
|
||||||
|
'匹配温州市典型场景参考指引…',
|
||||||
|
'生成分析报告…',
|
||||||
|
];
|
||||||
|
const [stepIdx, setStepIdx] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setStepIdx(i => (i + 1) % steps.length), 480);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
flex: 1, minWidth: 0,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '14px 18px', gap: 12, overflow: 'hidden',
|
||||||
|
perspective: '800px',
|
||||||
|
}}>
|
||||||
|
{/* 旋转卡片 */}
|
||||||
|
<div className="module-processing" style={{
|
||||||
|
width: 200, height: 160,
|
||||||
|
borderRadius: 22,
|
||||||
|
background: `linear-gradient(135deg, ${card.color}22, ${card.color}08)`,
|
||||||
|
border: `1.5px solid ${card.color}60`,
|
||||||
|
boxShadow: `0 0 48px ${card.color}30, inset 0 0 32px ${card.color}10`,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 12, position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* 扫描线 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 0, right: 0, height: 2,
|
||||||
|
background: `linear-gradient(90deg, transparent, ${card.color}80, transparent)`,
|
||||||
|
animation: 'scan-sweep 1.2s linear infinite',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
{/* 轨道环 - 外圈 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', width: 140, height: 140,
|
||||||
|
border: `1px dashed ${card.color}30`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'orbit-ring 3s linear infinite',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: -3, left: '50%', marginLeft: -3,
|
||||||
|
width: 6, height: 6, borderRadius: '50%',
|
||||||
|
background: card.color,
|
||||||
|
boxShadow: `0 0 8px ${card.color}`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
{/* 轨道环 - 内圈 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', width: 90, height: 90,
|
||||||
|
border: `1px dashed ${card.color}20`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'orbit-ring-rev 2s linear infinite',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: -3, left: '50%', marginLeft: -3,
|
||||||
|
width: 5, height: 5, borderRadius: '50%',
|
||||||
|
background: `${card.color}cc`,
|
||||||
|
boxShadow: `0 0 6px ${card.color}`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<TechIcon name={card.icon} color={card.color} size={38} />
|
||||||
|
<div style={{ color: card.color, fontSize: 13, fontWeight: 700, letterSpacing: '0.04em' }}>{card.label}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 处理中文字 */}
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, marginTop: 8, letterSpacing: '0.02em' }}>
|
||||||
|
{demand.company} · {steps[stepIdx]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 点点动画 */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<div key={i} style={{
|
||||||
|
width: 7, height: 7, borderRadius: '50%',
|
||||||
|
background: card.color,
|
||||||
|
animation: `processing-dots 1.2s ease-in-out ${i * 0.2}s infinite`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyWorkspace({ onDrop }: { onDrop: (module: AgentModule, event: DragEvent<HTMLDivElement>) => void }) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: 30 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(220px, 1fr))', gap: 18 }}>
|
||||||
|
{moduleCards.map(card => <ModuleCard key={card.key} card={card} onDrop={onDrop} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DemandSummary({ demand }: { demand: AIDemand }) {
|
||||||
|
const mCfg = maturityConfig[demand.maturityLevel];
|
||||||
|
const sCfg = statusConfig[demand.status];
|
||||||
|
const indColor = industryColors[demand.industry] || '#00d4ff';
|
||||||
|
const capabilityScores = capabilityScoreMap[demand.id] ?? [demand.dataReadiness, 20, 16, Math.max(70, demand.maturityScore), 82];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 14, background: 'rgba(0,20,60,0.6)', border: '1px solid rgba(0,212,255,0.2)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<MaturityGauge score={demand.maturityScore} color={mCfg.color} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ color: '#e8f4ff', fontSize: 18, fontWeight: 800 }}>{demand.company}</span>
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: `${sCfg.color}18`, border: `1px solid ${sCfg.color}40`, color: sCfg.color, fontSize: 12, fontWeight: 700 }}>{sCfg.label}</span>
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: `${indColor}18`, border: `1px solid ${indColor}35`, color: indColor, fontSize: 12, fontWeight: 700 }}>{demand.industry}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.76)', fontSize: 13, lineHeight: 1.65, margin: '8px 0 0' }}>{demand.painPoint}</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<RadarChart scores={capabilityScores} color={mCfg.color} />
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: 10, marginTop: -4 }}>AI能力图谱</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleWorkspace({ demand, module, onDrop }: { demand: AIDemand; module: AgentModule; onDrop: (module: AgentModule, event: DragEvent<HTMLDivElement>) => void }) {
|
||||||
|
const activeCard = moduleCards.find(card => card.key === module)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', padding: '14px 18px', gap: 12, overflow: 'hidden' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(120px, 1fr))', gap: 10, flexShrink: 0 }}>
|
||||||
|
{moduleCards.map(card => <ModuleCard key={card.key} card={card} inactive={!!module && card.key !== module} active={card.key === module} compact onDrop={onDrop} />)}
|
||||||
|
</div>
|
||||||
|
<DemandSummary demand={demand} />
|
||||||
|
<div className="space-scroll content-reveal" style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '16px 18px', borderRadius: 16, background: `${activeCard.color}08`, border: `1px solid ${activeCard.color}26`, boxShadow: `inset 0 0 32px ${activeCard.color}08` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||||
|
<TechIcon name={activeCard.icon} color={activeCard.color} size={34} />
|
||||||
|
<div>
|
||||||
|
<div style={{ color: activeCard.color, fontSize: 22, fontWeight: 850 }}>{activeCard.label}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.52)', fontSize: 12 }}>Agentic Space 已接收:{demand.company}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{module === 'maturity' && <MaturityModule demand={demand} />}
|
||||||
|
{module === 'translator' && <TranslatorModule demand={demand} />}
|
||||||
|
{module === 'roadmap' && <RoadmapModule demand={demand} />}
|
||||||
|
{module === 'coupon' && <CouponModule demand={demand} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaturityModule({ demand }: { demand: AIDemand }) {
|
||||||
|
const scores = capabilityScoreMap[demand.id] ?? [demand.dataReadiness, 20, 16, Math.max(70, demand.maturityScore), 82];
|
||||||
|
const mCfg = maturityConfig[demand.maturityLevel];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||||||
|
<div style={{ padding: 16, borderRadius: 12, background: 'rgba(0,212,255,0.06)', border: '1px solid rgba(0,212,255,0.2)' }}>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 16, fontWeight: 800, marginBottom: 10 }}>五维成熟度问诊结论</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.82)', fontSize: 14, lineHeight: 1.8, margin: 0 }}>
|
||||||
|
{demand.company} 当前处于 {mCfg.label},综合评分 {demand.maturityScore}/100。建议先围绕“{demand.scenario}”建立小范围试点,以数据就绪度、系统接入能力和业务KPI闭环作为首批评估指标。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 16, borderRadius: 12, background: 'rgba(0,20,60,0.5)', border: '1px solid rgba(0,212,255,0.16)' }}>
|
||||||
|
{capabilityLabels.map((label, index) => (
|
||||||
|
<div key={label} style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: 'rgba(255,255,255,0.72)', fontSize: 12, marginBottom: 4 }}><span>{label}</span><span>{scores[index]}%</span></div>
|
||||||
|
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${scores[index]}%`, height: '100%', borderRadius: 4, background: `linear-gradient(90deg, ${mCfg.color}88, ${mCfg.color})` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TranslatorModule({ demand }: { demand: AIDemand }) {
|
||||||
|
const mCfg = maturityConfig[demand.maturityLevel];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: '业务语言 · 痛点描述与量化', icon: 'target' as TechIconName, color: '#ff8c42',
|
||||||
|
text: demand.businessDesc || demand.painPoint,
|
||||||
|
extra: [
|
||||||
|
`核心问题指标:${demand.company}在“${demand.scenario}”场景中,当前${demand.painPoint}。`,
|
||||||
|
`问题导致的业务损失:生产效率受限、人工成本高企、质量波动影响交付稳定性。`,
|
||||||
|
`“理想状态”描述:通过AI技术实现该环节自动化或半自动化,减少人工依赖、提升一致性与可追溯性。`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数据与技术基础评估', icon: 'lab' as TechIconName, color: '#00d4ff',
|
||||||
|
text: demand.technicalDesc || `围绕${demand.scenario},需进一步确认数据来源、系统接口、现场部署环境和安全合规边界。`,
|
||||||
|
extra: [
|
||||||
|
`可用数据集:${demand.dataReadiness >= 70 ? '企业已具备一定结构化数据基础,数据覆盖率较高,可支撑AI建模起步' : '数据采集与结构化程度不足,建议先完成数据资产盘点与质量评估'}。`,
|
||||||
|
`现有IT与硬件设施:${demand.dataReadiness >= 60 ? '已有ERP/MES等系统覆盖核心业务流程,系统集成度待进一步提升' : '核心业务系统部署率较低,需优先补齐数字化基础设施'}。`,
|
||||||
|
`数源需求:需进一步确认${demand.scenario}场景下的关键数据字段、采集频率、标注情况和隐私合规要求。`,
|
||||||
|
`算力资源:建议评估本地部署与云端训练的混合方案,满足数据安全与推理效率的双重要求。`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI翻译官 · 需求转译与交付边界', icon: 'translator' as TechIconName, color: '#7b61ff',
|
||||||
|
text: `将“${demand.painPoint}”转译为可交付任务。`,
|
||||||
|
extra: [
|
||||||
|
`核心功能需求:${demand.suggestedSolution || '建议围绕该痛点构建AI辅助能力,明确输入数据格式、模型能力边界、系统集成接口和业务闭环流程'}。`,
|
||||||
|
`MVP验收标准:在试点数据集上,核心指标(准确率/召回率/效率提升)达到可量化目标;完成2-3家合作企业现场试用并收集反馈。`,
|
||||||
|
`最终验收标准:在真实生产场景中连续稳定运行1个月以上,核心KPI相比人工方式提升≥30%,方案具备向同产业其他企业复制推广能力。`,
|
||||||
|
`合作模式:建议采用“制造企业+AI翻译官+技术服务商”联合体模式,企业开放场景与数据,翻译官负责需求转译与技术边界定义,服务商负责交付实施。`,
|
||||||
|
`企业可投入资源:专项预算${demand.budget}、试点产线与真实生产环境、业务专家全程配合需求沟通与方案验证。`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.title} style={{ padding: '14px 16px', borderRadius: 12, background: `${item.color}08`, border: `1px solid ${item.color}22` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: item.color, fontSize: 15, fontWeight: 800, marginBottom: 8 }}><TechIcon name={item.icon} color={item.color} size={20} />{item.title}</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.88)', fontSize: 14, lineHeight: 1.8, margin: '0 0 8px', fontWeight: 600 }}>{item.text}</p>
|
||||||
|
{item.extra?.map((line, i) => (
|
||||||
|
<p key={i} style={{ color: 'rgba(255,255,255,0.72)', fontSize: 13, lineHeight: 1.75, margin: '0 0 4px', paddingLeft: 8, borderLeft: `2px solid ${item.color}30` }}>{line}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ padding: '10px 14px', borderRadius: 10, background: `${mCfg.color}08`, border: `1px solid ${mCfg.color}20` }}>
|
||||||
|
<div style={{ color: mCfg.color, fontSize: 13, fontWeight: 800, marginBottom: 6 }}>成熟度等级 {demand.maturityLevel} · 实施建议</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, lineHeight: 1.7, margin: 0 }}>
|
||||||
|
{demand.maturityLevel === 'L1' && '当前数据基础薄弱,建议先完成核心业务系统部署和数据采集体系建设,再启动AI场景试点。可先申领咨询券进行成熟度诊断。'}
|
||||||
|
{demand.maturityLevel === 'L2' && '具备初步数字化基础,建议聚焦1-2个高价值场景做小规模验证,同步补强数据治理。推荐先引入AI翻译官完成需求精化。'}
|
||||||
|
{demand.maturityLevel === 'L3' && '已有一定AI探索经验,建议推进场景规模化复制,重点打通数据孤岛和建设知识库。可申请算力券+工具券加速开发。'}
|
||||||
|
{demand.maturityLevel === 'L4' && 'AI能力较为成熟,建议构建自主AI开发平台,推进智能决策支持系统。可探索云边端混合部署与国产化适配。'}
|
||||||
|
{demand.maturityLevel === 'L5' && '行业AI领先水平,建议向AI驱动业务创新方向演进,输出行业解决方案,探索AI商业化与产业链赋能。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoadmapModule({ demand }: { demand: AIDemand }) {
|
||||||
|
const stages = [
|
||||||
|
{ phase: '阶段一', title: '现状诊断', duration: '1-2个月', color: '#ffb340', items: ['AI成熟度评估', '数据资产盘点', '场景识别优先级'], icon: 'analytics' as TechIconName },
|
||||||
|
{ phase: '阶段二', title: '需求精化', duration: '1-3个月', color: '#00d4ff', items: ['AI翻译官对接', '场景可行性分析', '技术方案评审'], icon: 'target' as TechIconName },
|
||||||
|
{ phase: '阶段三', title: '试点验证', duration: '3-6个月', color: '#7b61ff', items: ['小规模试点部署', 'ROI效果追踪', '方案迭代优化'], icon: 'lab' as TechIconName },
|
||||||
|
{ phase: '阶段四', title: '规模推广', duration: '6-12个月', color: '#00e599', items: ['扩大应用范围', '建立AI运维体系', '复制成功经验'], icon: 'deploy' as TechIconName },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.82)', fontSize: 14, lineHeight: 1.7, margin: '0 0 14px' }}>
|
||||||
|
目标场景:{demand.scenario}|预算参考:{demand.budget}|期望周期:{demand.timeline}。{demand.suggestedSolution || '建议采用“小切口试点、可量化验收、可复制推广”的温州AI+制造实施路径。'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(150px, 1fr))', gap: 12 }}>
|
||||||
|
{stages.map(stage => (
|
||||||
|
<div key={stage.phase} style={{ padding: '14px 14px', borderRadius: 12, background: `${stage.color}08`, border: `1px solid ${stage.color}25` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<TechIcon name={stage.icon} color={stage.color} size={24} />
|
||||||
|
<div>
|
||||||
|
<div style={{ color: stage.color, fontSize: 12, fontWeight: 800 }}>{stage.phase}</div>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 15, fontWeight: 800 }}>{stage.title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.45)', fontSize: 11, marginBottom: 8 }}>周期 {stage.duration}</div>
|
||||||
|
{stage.items.map(item => <div key={item} style={{ color: 'rgba(255,255,255,0.76)', fontSize: 12, lineHeight: 1.7 }}>· {item}</div>)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CouponModule({ demand }: { demand: AIDemand }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.78)', fontSize: 14, lineHeight: 1.7, marginBottom: 14 }}>
|
||||||
|
已按 {demand.region}、{demand.industry}、{demand.scenario} 自动匹配温州市AI+制造优惠券,建议优先申领咨询券、算力券和工具券。
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(180px, 1fr))', gap: 12 }}>
|
||||||
|
{COUPONS.map(coupon => {
|
||||||
|
const cfg = couponTypeConfig[coupon.type];
|
||||||
|
return (
|
||||||
|
<div key={coupon.id} style={{ padding: '14px 14px', borderRadius: 12, background: `${coupon.color}08`, border: `1px solid ${coupon.color}25` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<TechIcon name={(coupon.icon as TechIconName) || 'coupon'} color={coupon.color} size={26} />
|
||||||
|
<span style={{ padding: '2px 7px', borderRadius: 5, background: cfg.bg, color: cfg.color, fontSize: 11, fontWeight: 800 }}>{cfg.label}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 14, fontWeight: 800 }}>{coupon.title}</div>
|
||||||
|
<div style={{ color: coupon.color, fontSize: 22, fontWeight: 850, margin: '5px 0 3px' }}>{coupon.value}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.58)', fontSize: 12, lineHeight: 1.55 }}>{coupon.condition}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.42)', fontSize: 11, marginTop: 8, display: 'flex', alignItems: 'center', gap: 5 }}><TechIcon name="calendar" color="rgba(255,255,255,0.4)" size={12} />{coupon.expiry} · 余{coupon.remaining}张</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TranslatorWorkspace({ translator }: { translator: Translator }) {
|
||||||
|
const orgCfg = orgTypeConfig[translator.orgType];
|
||||||
|
const availabilityConfig = {
|
||||||
|
available: { label: '可接洽', color: '#00e599' },
|
||||||
|
busy: { label: '忙碌中', color: '#ffb340' },
|
||||||
|
offline: { label: '离线', color: 'rgba(255,255,255,0.35)' },
|
||||||
|
}[translator.availability];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', padding: '16px 18px', gap: 12, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '18px 20px', borderRadius: 14, background: `linear-gradient(135deg, ${translator.color}16 0%, rgba(0,20,60,0.72) 100%)`, border: `1px solid ${translator.color}35`, boxShadow: `0 0 28px ${translator.color}16`, display: 'flex', gap: 18, alignItems: 'flex-start', flexShrink: 0 }}>
|
||||||
|
<div style={{ width: 74, height: 74, borderRadius: 18, background: `linear-gradient(135deg, ${translator.color}26, ${translator.accentColor}18)`, border: `1px solid ${translator.color}55`, color: translator.color, fontSize: 30, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: `0 0 18px ${translator.color}30`, flexShrink: 0 }}>{translator.avatar}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ color: '#e8f4ff', fontSize: 22, fontWeight: 800 }}>{translator.name}</span>
|
||||||
|
<span style={{ padding: '3px 9px', borderRadius: 7, background: orgCfg.bg, border: `1px solid ${orgCfg.color}40`, color: orgCfg.color, fontSize: 12, fontWeight: 700 }}>{orgCfg.label}</span>
|
||||||
|
<span style={{ padding: '3px 9px', borderRadius: 7, background: `${availabilityConfig.color}18`, border: `1px solid ${availabilityConfig.color}40`, color: availabilityConfig.color, fontSize: 12, fontWeight: 700 }}>{availabilityConfig.label}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: translator.color, fontSize: 15, fontWeight: 700, marginTop: 4 }}>{translator.title}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.62)', fontSize: 13, marginTop: 3 }}>{translator.organization}</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.86)', fontSize: 14, lineHeight: 1.8, margin: '12px 0 0' }}>{translator.bio}</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexShrink: 0 }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '8px 12px', borderRadius: 10, background: 'rgba(255,179,64,0.08)', border: '1px solid rgba(255,179,64,0.25)' }}>
|
||||||
|
<div style={{ color: '#ffb340', fontSize: 20, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5 }}><TechIcon name="star" color="#ffb340" size={18} />{translator.rating}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11 }}>专家评分</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', padding: '8px 12px', borderRadius: 10, background: `${translator.color}0d`, border: `1px solid ${translator.color}30` }}>
|
||||||
|
<div style={{ color: translator.color, fontSize: 20, fontWeight: 800 }}>{translator.cases}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11 }}>服务案例</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', display: 'grid', gridTemplateColumns: '1.1fr 0.9fr', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 12, background: 'rgba(123,97,255,0.06)', border: '1px solid rgba(123,97,255,0.2)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#c8b4ff', fontSize: 14, fontWeight: 800, marginBottom: 10 }}><TechIcon name="translator" color="#7b61ff" size={18} /> 专长领域</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>{translator.expertise.map(item => <span key={item} style={{ padding: '6px 10px', borderRadius: 18, background: `${translator.color}12`, border: `1px solid ${translator.color}30`, color: translator.color, fontSize: 13, fontWeight: 700 }}>{item}</span>)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 12, background: 'rgba(0,212,255,0.06)', border: '1px solid rgba(0,212,255,0.18)' }}>
|
||||||
|
<div style={{ color: '#00d4ff', fontSize: 14, fontWeight: 800, marginBottom: 10 }}>适配温州产业</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>{translator.industries.map(item => <span key={item} style={{ padding: '6px 10px', borderRadius: 8, background: 'rgba(0,212,255,0.08)', border: '1px solid rgba(0,212,255,0.2)', color: 'rgba(255,255,255,0.82)', fontSize: 13 }}>{item}</span>)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 12, background: 'rgba(0,229,153,0.05)', border: '1px solid rgba(0,229,153,0.18)' }}>
|
||||||
|
<div style={{ color: '#00e599', fontSize: 14, fontWeight: 800, marginBottom: 10 }}>专业认证</div>
|
||||||
|
{translator.certifications.map(item => <div key={item} style={{ color: 'rgba(255,255,255,0.78)', fontSize: 13, lineHeight: 1.8 }}>· {item}</div>)}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 12, background: 'rgba(255,140,66,0.05)', border: '1px solid rgba(255,140,66,0.18)' }}>
|
||||||
|
<div style={{ color: '#ff8c42', fontSize: 14, fontWeight: 800, marginBottom: 10 }}>推荐对接方式</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.78)', fontSize: 13, lineHeight: 1.8 }}>· 企业提交场景痛点、现有系统、数据样本与产线约束条件<br />· AI翻译官输出业务语言版需求说明,明确痛点量化指标与“理想状态”目标<br />· 翻译官定义技术边界:输入数据格式、模型能力范围、系统集成接口、验收KPI<br />· 输出MVP验收标准与最终项目验收标准,确保可量化、可追踪<br />· 进入落地规划、优惠券匹配和服务商对接,形成“企业+翻译官+服务商”联合体</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DemandDetailView({ demand }: { demand: AIDemand }) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', padding: '10px 16px', gap: 10, overflow: 'hidden' }}>
|
||||||
|
<DemandSummary demand={demand} />
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', flexShrink: 0 }}>
|
||||||
|
{demand.tags.map(tag => <span key={tag} style={{ padding: '3px 10px', borderRadius: 20, background: 'rgba(0,212,255,0.08)', border: '1px solid rgba(0,212,255,0.2)', color: '#a8d8ff', fontSize: 12 }}>#{tag}</span>)}
|
||||||
|
</div>
|
||||||
|
<TranslatorModule demand={demand} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CenterDemandDetail({ demand, translator, resetKey = 0 }: CenterDemandDetailProps) {
|
||||||
|
const [activeModule, setActiveModule] = useState<AgentModule | null>(null);
|
||||||
|
const [moduleDemand, setModuleDemand] = useState<AIDemand | null>(null);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [pendingModule, setPendingModule] = useState<AgentModule | null>(null);
|
||||||
|
const [pendingDemand, setPendingDemand] = useState<AIDemand | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveModule(null);
|
||||||
|
setModuleDemand(null);
|
||||||
|
setProcessing(false);
|
||||||
|
setPendingModule(null);
|
||||||
|
setPendingDemand(null);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (demand) {
|
||||||
|
setActiveModule(null);
|
||||||
|
setModuleDemand(null);
|
||||||
|
setProcessing(false);
|
||||||
|
setPendingModule(null);
|
||||||
|
setPendingDemand(null);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
}, [demand?.id]);
|
||||||
|
|
||||||
|
const handleModuleDrop = (module: AgentModule, event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const demandId = event.dataTransfer.getData('application/x-ai-demand-id');
|
||||||
|
const droppedDemand = DEMANDS.find(item => item.id === demandId);
|
||||||
|
if (!droppedDemand) return;
|
||||||
|
// 先进入处理中动画状态
|
||||||
|
setPendingModule(module);
|
||||||
|
setPendingDemand(droppedDemand);
|
||||||
|
setProcessing(true);
|
||||||
|
setActiveModule(null);
|
||||||
|
setModuleDemand(null);
|
||||||
|
// 2.2s 后再展示真实内容
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setProcessing(false);
|
||||||
|
setModuleDemand(droppedDemand);
|
||||||
|
setActiveModule(module);
|
||||||
|
}, 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (translator) return <TranslatorWorkspace translator={translator} />;
|
||||||
|
if (processing && pendingModule && pendingDemand)
|
||||||
|
return <ProcessingScreen module={pendingModule} demand={pendingDemand} />;
|
||||||
|
if (activeModule && moduleDemand) return <ModuleWorkspace demand={moduleDemand} module={activeModule} onDrop={handleModuleDrop} />;
|
||||||
|
if (!demand) return <EmptyWorkspace onDrop={handleModuleDrop} />;
|
||||||
|
return <DemandDetailView demand={demand} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { DEMANDS, maturityConfig, statusConfig, type AIDemand } from '@/shared/data/demands';
|
||||||
|
import TechIcon from '@/shared/components/TechIcons';
|
||||||
|
|
||||||
|
interface LeftDemandPanelProps {
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (demand: AIDemand) => void;
|
||||||
|
onExpandChange?: (expanded: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const industryColors: Record<string, string> = {
|
||||||
|
'智能电气': '#00d4ff',
|
||||||
|
'鞋业服装': '#ff6b9d',
|
||||||
|
'汽车零部件': '#ff8c42',
|
||||||
|
'工业泵阀': '#7b61ff',
|
||||||
|
'智能装备': '#4fc3f7',
|
||||||
|
'新能源': '#00e599',
|
||||||
|
'新材料': '#ffb340',
|
||||||
|
'智能物联': '#35d0ba',
|
||||||
|
'眼镜': '#9bdcff',
|
||||||
|
'食品农产品': '#8bd450',
|
||||||
|
'低空经济': '#b58cff',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeftDemandPanel({ selectedId, onSelect, onExpandChange }: LeftDemandPanelProps) {
|
||||||
|
const [filter, setFilter] = useState<'all' | 'new' | 'matching' | 'matched' | 'pilot'>('all');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const filtered = filter === 'all' ? DEMANDS : DEMANDS.filter(d => d.status === filter);
|
||||||
|
const urgentCount = DEMANDS.filter(d => d.urgency === 'high').length;
|
||||||
|
|
||||||
|
const handleExpand = () => {
|
||||||
|
const next = !expanded;
|
||||||
|
setExpanded(next);
|
||||||
|
onExpandChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const W = expanded ? 460 : 260;
|
||||||
|
const BULGE = 28;
|
||||||
|
const totalW = W + BULGE;
|
||||||
|
|
||||||
|
const outlinePath = `M 0,0 L ${W - 70},0 Q ${W},0 ${W},80 C ${W + BULGE},2000 ${W + BULGE},8000 ${W},9920 L 0,10000 Z`;
|
||||||
|
const strokePath = `M 0,0.5 L ${W - 70},0.5 Q ${W - 0.5},0.5 ${W - 0.5},80 C ${W - 0.5 + BULGE},2000 ${W - 0.5 + BULGE},8000 ${W - 0.5},9920`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-left" style={{
|
||||||
|
width: totalW, height: '100%', flexShrink: 0,
|
||||||
|
position: 'relative', overflow: 'visible',
|
||||||
|
background: 'transparent',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
<svg style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, overflow: 'visible' }}
|
||||||
|
viewBox={`0 0 ${totalW} 10000`} preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="leftDemandBg" x1="1" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(4,28,75,0.88)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(2,14,48,0.78)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={outlinePath} fill="url(#leftDemandBg)" />
|
||||||
|
<path d={strokePath} fill="none" stroke="rgba(0,212,255,0.45)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<line x1="0.5" y1="0" x2="0.5" y2="10000" stroke="rgba(0,212,255,0.15)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W - 70} cy="1" r="3" fill="rgba(0,212,255,0.9)" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W} cy="80" r="2.5" fill="rgba(0,212,255,0.6)" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W + BULGE} cy="5000" r="2" fill="rgba(0,212,255,0.25)" vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1, width: W,
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
{/* 标题区 */}
|
||||||
|
<div style={{ padding: '14px 12px 10px 14px', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<span style={{ width: 3, height: 16, borderRadius: 2, background: 'linear-gradient(to bottom,#00d4ff,#0066aa)', display: 'inline-block' }} />
|
||||||
|
<span style={{ color: '#a8d8ff', fontSize: 16, fontWeight: 600, letterSpacing: '0.04em' }}>企业AI需求</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{urgentCount > 0 && (
|
||||||
|
<span style={{ padding: '1px 7px', borderRadius: 8, background: 'rgba(255,140,66,0.2)', color: '#ff8c42', fontSize: 12, fontWeight: 700 }}>
|
||||||
|
{urgentCount}紧急
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={handleExpand} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 7,
|
||||||
|
border: `1px solid ${expanded ? 'rgba(0,212,255,0.4)' : 'rgba(0,212,255,0.2)'}`,
|
||||||
|
background: expanded ? 'rgba(0,212,255,0.1)' : 'transparent',
|
||||||
|
color: expanded ? '#00d4ff' : 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{expanded ? <><TechIcon name="collapse" color="currentColor" size={13} /> 收起</> : <><TechIcon name="expand" color="currentColor" size={13} /> 展开</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选标签 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 10, flexWrap: 'wrap' }}>
|
||||||
|
{(['all', 'new', 'matching', 'pilot'] as const).map(s => {
|
||||||
|
const isActive = filter === s;
|
||||||
|
const cfg = s !== 'all' ? statusConfig[s] : null;
|
||||||
|
return (
|
||||||
|
<button key={s} onClick={() => setFilter(s)} style={{
|
||||||
|
flex: '1 1 0', minWidth: 0, padding: '4px 0', borderRadius: 5,
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1px solid ${isActive ? (cfg?.color + '60' || 'rgba(0,212,255,0.4)') : 'rgba(255,255,255,0.12)'}`,
|
||||||
|
background: isActive ? (cfg?.color + '18' || 'rgba(0,212,255,0.12)') : 'transparent',
|
||||||
|
color: isActive ? (cfg?.color || '#00d4ff') : 'rgba(255,255,255,0.55)',
|
||||||
|
}}>
|
||||||
|
{s === 'all' ? '全部' : cfg!.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'rgba(0,212,255,0.1)', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 需求列表 */}
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 8px 14px' }}>
|
||||||
|
{filtered.map((demand, idx) => {
|
||||||
|
const isSelected = selectedId === demand.id;
|
||||||
|
const mCfg = maturityConfig[demand.maturityLevel];
|
||||||
|
const sCfg = statusConfig[demand.status];
|
||||||
|
const indColor = industryColors[demand.industry] || '#00d4ff';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={demand.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={e => {
|
||||||
|
e.dataTransfer.setData('application/x-ai-demand-id', demand.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
}}
|
||||||
|
onClick={() => onSelect(demand)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 11px', borderRadius: 10, marginBottom: 7,
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff55' : 'rgba(0,212,255,0.12)'}`,
|
||||||
|
background: isSelected
|
||||||
|
? 'linear-gradient(135deg, rgba(0,212,255,0.12) 0%, rgba(0,100,200,0.06) 100%)'
|
||||||
|
: 'rgba(0,20,50,0.35)',
|
||||||
|
cursor: 'pointer', transition: 'all 0.18s ease',
|
||||||
|
animation: `notice-in 0.3s ${idx * 0.05}s both`,
|
||||||
|
boxShadow: isSelected ? '0 0 14px rgba(0,212,255,0.15)' : 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,30,70,0.6)'; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,20,50,0.35)'; }}
|
||||||
|
>
|
||||||
|
{/* 顶行:公司名 + 状态 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: isSelected ? '#00d4ff' : '#c8e4ff', fontSize: 13, fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{demand.company}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(0,212,255,0.62)', fontSize: 10, marginTop: 2, letterSpacing: '0.04em' }}>
|
||||||
|
拖拽至中间模块生成Agentic内容
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11, marginTop: 1 }}>
|
||||||
|
{demand.region}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 6px', borderRadius: 5, flexShrink: 0,
|
||||||
|
background: `${sCfg.color}18`, border: `1px solid ${sCfg.color}40`,
|
||||||
|
color: sCfg.color, fontSize: 11, fontWeight: 600,
|
||||||
|
}}>{sCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 痛点描述 */}
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.78)', fontSize: 12, margin: '6px 0 5px', lineHeight: 1.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{demand.painPoint}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 底行:行业标签 + 成熟度 + 场景 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 4, background: `${indColor}18`, border: `1px solid ${indColor}35`, color: indColor, fontSize: 11, fontWeight: 600 }}>
|
||||||
|
{demand.industry}
|
||||||
|
</span>
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 4, background: mCfg.bg, border: `1px solid ${mCfg.color}40`, color: mCfg.color, fontSize: 11, fontWeight: 600 }}>
|
||||||
|
{mCfg.label}
|
||||||
|
</span>
|
||||||
|
{demand.urgency === 'high' && (
|
||||||
|
<span style={{ padding: '1px 5px', borderRadius: 4, background: 'rgba(255,140,66,0.12)', color: '#ff8c42', fontSize: 10, fontWeight: 700 }}>
|
||||||
|
!紧急
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 成熟度进度条 */}
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div style={{ flex: 1, height: 3, borderRadius: 2, background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${demand.maturityScore}%`, height: '100%', background: `linear-gradient(to right, ${mCfg.color}88, ${mCfg.color})`, borderRadius: 2, transition: 'width 0.5s ease' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: mCfg.color, fontSize: 11, fontWeight: 700, flexShrink: 0 }}>{demand.maturityScore}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { TRANSLATORS, orgTypeConfig, type Translator } from '@/shared/data/translators';
|
||||||
|
import TechIcon from '@/shared/components/TechIcons';
|
||||||
|
|
||||||
|
interface RightTranslatorPanelProps {
|
||||||
|
onTranslatorSelect: (t: Translator) => void;
|
||||||
|
selectedId: number | null;
|
||||||
|
onExpandChange?: (expanded: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabilityConfig = {
|
||||||
|
available: { label: '可接洽', color: '#00e599' },
|
||||||
|
busy: { label: '忙碌中', color: '#ffb340' },
|
||||||
|
offline: { label: '离线', color: 'rgba(255,255,255,0.3)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'vendor' | 'university' | 'enterprise';
|
||||||
|
|
||||||
|
export default function RightTranslatorPanel({ onTranslatorSelect, selectedId, onExpandChange }: RightTranslatorPanelProps) {
|
||||||
|
const [filter, setFilter] = useState<FilterType>('all');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const filtered = filter === 'all' ? TRANSLATORS : TRANSLATORS.filter(t => t.orgType === filter);
|
||||||
|
const availableCount = TRANSLATORS.filter(t => t.availability === 'available').length;
|
||||||
|
|
||||||
|
const handleExpand = () => {
|
||||||
|
const next = !expanded;
|
||||||
|
setExpanded(next);
|
||||||
|
onExpandChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BULGE = 28;
|
||||||
|
const W = expanded ? 460 : 260;
|
||||||
|
|
||||||
|
const outlinePath = `M ${W},0 L 70,0 Q 0,0 0,80 C ${-BULGE},2000 ${-BULGE},8000 0,9920 L ${W},10000 Z`;
|
||||||
|
const strokePath = `M ${W},0.5 L 70,0.5 Q 0.5,0.5 0.5,80 C ${-BULGE + 0.5},2000 ${-BULGE + 0.5},8000 0.5,9920`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-right" style={{
|
||||||
|
width: W + BULGE, height: '100%', flexShrink: 0,
|
||||||
|
position: 'relative', overflow: 'visible', background: 'transparent',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
<svg style={{
|
||||||
|
position: 'absolute', top: 0, left: -BULGE,
|
||||||
|
width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, overflow: 'visible',
|
||||||
|
}} viewBox={`${-BULGE} 0 ${W + BULGE} 10000`} preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="rightTranslatorBg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(4,28,75,0.88)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(2,14,48,0.78)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={outlinePath} fill="url(#rightTranslatorBg)" />
|
||||||
|
<path d={strokePath} fill="none" stroke="rgba(0,212,255,0.45)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<line x1={W - 0.5} y1="0" x2={W - 0.5} y2="10000" stroke="rgba(0,212,255,0.15)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="70" cy="1" r="3" fill="rgba(0,212,255,0.9)" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="0" cy="80" r="2.5" fill="rgba(0,212,255,0.6)" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={-BULGE} cy="5000" r="2" fill="rgba(0,212,255,0.25)" vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1, marginLeft: BULGE, width: W,
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
{/* 标题区 */}
|
||||||
|
<div style={{ padding: '14px 14px 10px 12px', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<span style={{ width: 3, height: 16, borderRadius: 2, background: 'linear-gradient(to bottom,#7b61ff,#4433cc)', display: 'inline-block' }} />
|
||||||
|
<span style={{ color: '#c8b4ff', fontSize: 16, fontWeight: 600, letterSpacing: '0.04em' }}>AI翻译官</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 8, background: 'rgba(0,229,153,0.15)', color: '#00e599', fontSize: 12, fontWeight: 700 }}>
|
||||||
|
{availableCount}可接洽
|
||||||
|
</span>
|
||||||
|
<button onClick={handleExpand} style={{
|
||||||
|
padding: '2px 8px', borderRadius: 7,
|
||||||
|
border: `1px solid ${expanded ? 'rgba(123,97,255,0.4)' : 'rgba(123,97,255,0.2)'}`,
|
||||||
|
background: expanded ? 'rgba(123,97,255,0.1)' : 'transparent',
|
||||||
|
color: expanded ? '#7b61ff' : 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{expanded ? <><TechIcon name="collapse" color="currentColor" size={13} /> 收起</> : <><TechIcon name="expand" color="currentColor" size={13} /> 展开</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 类型筛选 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 10 }}>
|
||||||
|
{(['all', 'vendor', 'university', 'enterprise'] as FilterType[]).map(f => {
|
||||||
|
const isActive = filter === f;
|
||||||
|
const cfg = f !== 'all' ? orgTypeConfig[f] : null;
|
||||||
|
return (
|
||||||
|
<button key={f} onClick={() => setFilter(f)} style={{
|
||||||
|
flex: 1, padding: '4px 0', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1px solid ${isActive ? (cfg?.color + '50' || 'rgba(123,97,255,0.4)') : 'rgba(255,255,255,0.1)'}`,
|
||||||
|
background: isActive ? (cfg?.bg || 'rgba(123,97,255,0.12)') : 'transparent',
|
||||||
|
color: isActive ? (cfg?.color || '#7b61ff') : 'rgba(255,255,255,0.5)',
|
||||||
|
}}>
|
||||||
|
{f === 'all' ? '全部' : cfg!.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginTop: 8, alignItems: 'center' }}>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.4)', fontSize: 11 }}>接洽率</span>
|
||||||
|
<div style={{ flex: 1, height: 2, borderRadius: 1, background: 'rgba(123,97,255,0.1)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${(availableCount / TRANSLATORS.length) * 100}%`, height: '100%', background: 'linear-gradient(to right, #7b61ff, #00e599)', borderRadius: 1 }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#7b61ff', fontSize: 11 }}>{availableCount}/{TRANSLATORS.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'rgba(123,97,255,0.15)', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 翻译官列表 */}
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px 8px 12px' }}>
|
||||||
|
{filtered.map((translator, idx) => {
|
||||||
|
const isSelected = selectedId === translator.id;
|
||||||
|
const orgCfg = orgTypeConfig[translator.orgType];
|
||||||
|
const availCfg = availabilityConfig[translator.availability];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={translator.id}
|
||||||
|
onClick={() => onTranslatorSelect(translator)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 10px', borderRadius: 10, marginBottom: 7,
|
||||||
|
border: `1px solid ${isSelected ? translator.color + '60' : 'rgba(123,97,255,0.15)'}`,
|
||||||
|
background: isSelected
|
||||||
|
? `linear-gradient(135deg, ${translator.color}12 0%, ${translator.accentColor}08 100%)`
|
||||||
|
: 'rgba(10,5,40,0.4)',
|
||||||
|
cursor: 'pointer', transition: 'all 0.18s ease',
|
||||||
|
animation: `notice-in 0.3s ${idx * 0.06}s both`,
|
||||||
|
boxShadow: isSelected ? `0 0 14px ${translator.color}20` : 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(30,20,70,0.6)'; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(10,5,40,0.4)'; }}
|
||||||
|
>
|
||||||
|
{/* 顶行:头像 + 姓名 + 状态 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: `linear-gradient(135deg, ${translator.color}22 0%, ${translator.accentColor}12 100%)`,
|
||||||
|
border: `1px solid ${translator.color}40`,
|
||||||
|
color: translator.color, fontSize: 15, fontWeight: 700,
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
boxShadow: isSelected ? `0 0 12px ${translator.color}30` : 'none',
|
||||||
|
}}>
|
||||||
|
{isSelected && (
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: '-100%', width: '60%', height: '100%', background: `linear-gradient(to right, transparent, ${translator.color}25, transparent)`, animation: 'space-border-scan 1.5s linear infinite' }} />
|
||||||
|
)}
|
||||||
|
{translator.avatar}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: isSelected ? translator.color : '#c8e4ff', fontSize: 14, fontWeight: 700, textShadow: isSelected ? `0 0 8px ${translator.color}80` : 'none' }}>
|
||||||
|
{translator.name}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: availCfg.color, boxShadow: `0 0 5px ${availCfg.color}`, display: 'inline-block', animation: translator.availability === 'available' ? 'space-pulse 2s ease-in-out infinite' : 'none' }} />
|
||||||
|
<span style={{ color: availCfg.color, fontSize: 11 }}>{availCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11 }}>{translator.title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 机构 + 类型标签 */}
|
||||||
|
<div style={{ paddingLeft: 44, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: 12, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{translator.organization}</span>
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 4, background: orgCfg.bg, color: orgCfg.color, fontSize: 10, fontWeight: 600, flexShrink: 0 }}>{orgCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 简介 */}
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, margin: '6px 0 5px', paddingLeft: 44, lineHeight: 1.55, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{translator.bio}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 指标:评分 + 案例 */}
|
||||||
|
<div style={{ paddingLeft: 44, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||||
|
<TechIcon name="star" color="#ffb340" size={12} />
|
||||||
|
<span style={{ color: '#ffb340', fontSize: 13, fontWeight: 700 }}>{translator.rating}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '2px 6px', borderRadius: 4, background: `${translator.color}10`, border: `1px solid ${translator.color}25` }}>
|
||||||
|
<span style={{ color: translator.color, fontSize: 12, fontWeight: 700 }}>{translator.cases}</span>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11 }}> 案例</span>
|
||||||
|
</div>
|
||||||
|
{/* 专长 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, flex: 1, overflow: 'hidden' }}>
|
||||||
|
{translator.expertise.slice(0, 2).map(e => (
|
||||||
|
<span key={e} style={{ padding: '1px 5px', borderRadius: 3, background: `${translator.color}10`, color: translator.color, fontSize: 10, whiteSpace: 'nowrap' }}>{e}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开态:认证信息 */}
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ marginTop: 8, paddingLeft: 44, animation: 'notice-in 0.2s both' }}>
|
||||||
|
<div style={{ padding: '6px 10px', borderRadius: 7, background: 'rgba(0,10,30,0.45)', border: `1px solid ${translator.color}20` }}>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11, marginBottom: 4 }}>专业认证</div>
|
||||||
|
{translator.certifications.map(c => (
|
||||||
|
<div key={c} style={{ color: 'rgba(255,255,255,0.75)', fontSize: 12, lineHeight: 1.5 }}>· {c}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ModuleConfig } from '../registry';
|
||||||
|
|
||||||
|
export const moduleConfig: ModuleConfig = {
|
||||||
|
id: 'ai-matching',
|
||||||
|
name: 'AI+制造 智创空间',
|
||||||
|
description: '温州市"AI+制造"智创空间,智能匹配企业AI需求与AI翻译师',
|
||||||
|
icon: 'BrainCircuit',
|
||||||
|
path: '/ai-matching',
|
||||||
|
accentColor: '#00d4ff',
|
||||||
|
accentColorLight: '#1a56db',
|
||||||
|
gradient: 'linear-gradient(135deg, #001a4a, #003366, #004488)',
|
||||||
|
gradientLight: 'linear-gradient(135deg, #f0f7ff, #e8f0fe, #dbeafe)',
|
||||||
|
tags: ['AI', '制造', '匹配'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTheme } from '@/shared/context/ThemeContext';
|
||||||
|
import StarBackground from '@/shared/components/StarBackground';
|
||||||
|
import AIMHeader from '../components/AIMHeader';
|
||||||
|
import LeftDemandPanel from '../components/LeftDemandPanel';
|
||||||
|
import CenterDemandDetail from '../components/CenterDemandDetail';
|
||||||
|
import RightTranslatorPanel from '../components/RightTranslatorPanel';
|
||||||
|
import BottomAIBar from '../components/BottomAIBar';
|
||||||
|
import type { AIDemand } from '@/shared/data/demands';
|
||||||
|
import type { Translator } from '@/shared/data/translators';
|
||||||
|
|
||||||
|
export default function AIMCockpit() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [selectedDemand, setSelectedDemand] = useState<AIDemand | null>(null);
|
||||||
|
const [selectedTranslator, setSelectedTranslator] = useState<Translator | null>(null);
|
||||||
|
const [leftExpanded, setLeftExpanded] = useState(false);
|
||||||
|
const [rightExpanded, setRightExpanded] = useState(false);
|
||||||
|
const [workspaceResetKey, setWorkspaceResetKey] = useState(0);
|
||||||
|
|
||||||
|
const handleDemandSelect = (d: AIDemand) => {
|
||||||
|
setSelectedDemand(d);
|
||||||
|
setSelectedTranslator(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTranslatorSelect = (t: Translator) =>
|
||||||
|
setSelectedTranslator(prev => prev?.id === t.id ? null : t);
|
||||||
|
|
||||||
|
const resetWorkspace = () => {
|
||||||
|
setSelectedDemand(null);
|
||||||
|
setSelectedTranslator(null);
|
||||||
|
setWorkspaceResetKey(key => key + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100vw', height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: "-apple-system, 'SF Pro Text', 'PingFang SC', sans-serif",
|
||||||
|
color: '#a8d8ff',
|
||||||
|
}}>
|
||||||
|
{/* 星空背景 */}
|
||||||
|
<StarBackground theme={theme} />
|
||||||
|
|
||||||
|
{/* 内容层 */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
|
||||||
|
{/* 顶部标题栏 */}
|
||||||
|
<AIMHeader />
|
||||||
|
|
||||||
|
{/* 留白 */}
|
||||||
|
<div style={{ height: 14, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 中部三栏区域 */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', minHeight: 0, position: 'relative', gap: 0, padding: '0 0' }}>
|
||||||
|
|
||||||
|
{/* 左侧:企业AI需求面板,顶部留出80px */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', flexShrink: 0,
|
||||||
|
marginTop: 80, height: '100%',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
width: leftExpanded ? 508 : 288,
|
||||||
|
}}>
|
||||||
|
<LeftDemandPanel
|
||||||
|
selectedId={selectedDemand?.id ?? null}
|
||||||
|
onSelect={handleDemandSelect}
|
||||||
|
onExpandChange={setLeftExpanded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 间隙 */}
|
||||||
|
<div style={{ width: 14, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 中央:需求详情区 */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', position: 'relative', marginTop: 36 }}>
|
||||||
|
{/* 全息边框容器 */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, borderRadius: 16, overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(0,212,255,0.28)',
|
||||||
|
boxShadow: '0 0 30px rgba(0,212,255,0.1), 0 0 60px rgba(0,100,200,0.06), inset 0 0 30px rgba(0,30,80,0.15)',
|
||||||
|
background: 'rgba(0,15,45,0.55)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* 四角装饰 */}
|
||||||
|
{['top-left', 'top-right', 'bottom-left', 'bottom-right'].map(pos => {
|
||||||
|
const isTop = pos.includes('top');
|
||||||
|
const isLeft = pos.includes('left');
|
||||||
|
return (
|
||||||
|
<div key={pos} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: isTop ? 8 : undefined,
|
||||||
|
bottom: !isTop ? 8 : undefined,
|
||||||
|
left: isLeft ? 8 : undefined,
|
||||||
|
right: !isLeft ? 8 : undefined,
|
||||||
|
width: 16, height: 16, zIndex: 5, pointerEvents: 'none',
|
||||||
|
borderTop: isTop ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
borderBottom: !isTop ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
borderLeft: isLeft ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
borderRight: !isLeft ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 中央标题栏 */}
|
||||||
|
<div style={{ padding: '12px 16px 10px', borderBottom: '1px solid rgba(0,212,255,0.1)', display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', gap: 10, flexShrink: 0 }}>
|
||||||
|
<div />
|
||||||
|
<span style={{ color: '#a8d8ff', fontSize: 21, fontWeight: 800, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center' }}>
|
||||||
|
智创空间 Agentic Space
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
onClick={resetWorkspace}
|
||||||
|
title="点击返回智创空间初始界面"
|
||||||
|
style={{ alignSelf: 'stretch', cursor: 'pointer', display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{selectedTranslator ? (
|
||||||
|
<span style={{ padding: '3px 9px', borderRadius: 7, background: 'rgba(123,97,255,0.08)', border: '1px solid rgba(123,97,255,0.2)', color: '#7b61ff', fontSize: 12, fontWeight: 700 }}>
|
||||||
|
@{selectedTranslator.name}
|
||||||
|
</span>
|
||||||
|
) : selectedDemand && (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: 'rgba(0,212,255,0.06)', border: '1px solid rgba(0,212,255,0.15)', color: '#00d4ff', fontSize: 12 }}>
|
||||||
|
{selectedDemand.scenario}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中央内容 */}
|
||||||
|
<CenterDemandDetail demand={selectedDemand} translator={selectedTranslator} resetKey={workspaceResetKey} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 间隙 */}
|
||||||
|
<div style={{ width: 36, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 右侧:AI翻译官面板 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', flexShrink: 0,
|
||||||
|
marginTop: 80, height: '100%',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
width: rightExpanded ? 508 : 288,
|
||||||
|
}}>
|
||||||
|
<RightTranslatorPanel
|
||||||
|
onTranslatorSelect={handleTranslatorSelect}
|
||||||
|
selectedId={selectedTranslator?.id ?? null}
|
||||||
|
onExpandChange={setRightExpanded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部留白 */}
|
||||||
|
<div style={{ height: 12, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 底部智能问答工作区 */}
|
||||||
|
<BottomAIBar
|
||||||
|
selectedDemand={selectedDemand}
|
||||||
|
selectedTranslator={selectedTranslator}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { ConfigProvider, Layout, Menu, Button } from 'antd'
|
||||||
|
import {
|
||||||
|
SettingOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
|
|
||||||
|
import SysParameterPage from './pages/SysParameterPage'
|
||||||
|
import EquipmentCapacityPage from './pages/EquipmentCapacityPage'
|
||||||
|
import ProductInfoPage from './pages/ProductInfoPage'
|
||||||
|
import ProductEquipmentLoadPage from './pages/ProductEquipmentLoadPage'
|
||||||
|
import SchedulePage from './pages/SchedulePage'
|
||||||
|
import RealtimeMonitorPage from './pages/RealtimeMonitorPage'
|
||||||
|
import CapacityEvaluationPage from './pages/CapacityEvaluationPage'
|
||||||
|
|
||||||
|
const { Sider, Content } = Layout
|
||||||
|
|
||||||
|
const BASE_PATH = '/bexell-scheduling-ai'
|
||||||
|
|
||||||
|
// 菜单分组配置
|
||||||
|
const menuGroups = [
|
||||||
|
{
|
||||||
|
title: '监控中心',
|
||||||
|
items: [
|
||||||
|
{ key: `${BASE_PATH}/realtime-monitor`, icon: <ClockCircleOutlined />, label: '实时产能监控' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '排产管理',
|
||||||
|
items: [
|
||||||
|
{ key: `${BASE_PATH}/product-equipment-load`, icon: <DashboardOutlined />, label: '产能负荷' },
|
||||||
|
{ key: `${BASE_PATH}/schedule`, icon: <CalendarOutlined />, label: '排产明细' },
|
||||||
|
{ key: `${BASE_PATH}/capacity-evaluation`, icon: <ThunderboltOutlined />, label: '产能评估' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '基础数据',
|
||||||
|
items: [
|
||||||
|
{ key: `${BASE_PATH}/equipment-capacity`, icon: <ToolOutlined />, label: '设备产能' },
|
||||||
|
{ key: `${BASE_PATH}/product-info`, icon: <TagsOutlined />, label: '品号信息' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '系统管理',
|
||||||
|
items: [
|
||||||
|
{ key: `${BASE_PATH}/sys-parameters`, icon: <SettingOutlined />, label: '系统参数' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 页面标题映射
|
||||||
|
const pageTitles: Record<string, { title: string; subtitle: string }> = {
|
||||||
|
[`${BASE_PATH}/sys-parameters`]: { title: '系统管理', subtitle: '系统参数配置与维护' },
|
||||||
|
[`${BASE_PATH}/equipment-capacity`]: { title: '基础数据', subtitle: '设备产能信息管理' },
|
||||||
|
[`${BASE_PATH}/product-info`]: { title: '基础数据', subtitle: '产品品号信息管理' },
|
||||||
|
[`${BASE_PATH}/product-equipment-load`]: { title: '排产管理', subtitle: '品号设备产能负荷配置' },
|
||||||
|
[`${BASE_PATH}/schedule`]: { title: '排产管理', subtitle: '品号设备排产明细管理' },
|
||||||
|
[`${BASE_PATH}/capacity-evaluation`]: { title: '排产管理', subtitle: '试产设备产能评估与推荐' },
|
||||||
|
[`${BASE_PATH}/realtime-monitor`]: { title: '监控中心', subtitle: '实时产线监控与产能概览' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppLayout: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const menuItems = menuGroups.flatMap((group) => [
|
||||||
|
{ type: 'group' as const, label: group.title, children: group.items },
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentPage = pageTitles[location.pathname] || { title: '', subtitle: '' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
|
{/* ===== 固定侧边栏 ===== */}
|
||||||
|
<Sider
|
||||||
|
theme="light"
|
||||||
|
width={240}
|
||||||
|
style={{
|
||||||
|
borderRight: '1px solid #f0f0f0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '100vh',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo 区域 - 固定顶部 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 64,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 20px',
|
||||||
|
borderBottom: '1px solid #f5f5f5',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/bexell/logo.png"
|
||||||
|
alt="BEXELL"
|
||||||
|
style={{ height: 32, marginRight: 10 }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#1a1a1a',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
试产排程评估
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 菜单区域 - 可滚动 */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[location.pathname]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={({ key }) => navigate(key)}
|
||||||
|
style={{
|
||||||
|
borderRight: 0,
|
||||||
|
paddingTop: 8,
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部状态栏 - 固定至左侧下方 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderTop: '1px solid #f5f5f5',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8c8c8c',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
background: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
size="small"
|
||||||
|
block
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
style={{ fontWeight: 600, color: '#1677ff', borderColor: '#1677ff' }}
|
||||||
|
>
|
||||||
|
← 返回平台
|
||||||
|
</Button>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#52c41a',
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
系统运行正常
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sider>
|
||||||
|
|
||||||
|
{/* ===== 主内容区 ===== */}
|
||||||
|
<Layout style={{ overflow: 'hidden', height: '100vh' }}>
|
||||||
|
{/* 页面标题栏 - 固定顶部,滚动不受影响 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
background: '#f0f2f5',
|
||||||
|
padding: '20px 32px 16px 32px',
|
||||||
|
borderBottom: '1px solid #e8e8e8',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#1a1a1a',
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentPage.title}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#8c8c8c',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentPage.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 可滚动内容区 */}
|
||||||
|
<Content
|
||||||
|
style={{
|
||||||
|
background: '#f0f2f5',
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '16px 32px 32px 32px',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<Navigate to="realtime-monitor" replace />} />
|
||||||
|
<Route path="sys-parameters" element={<SysParameterPage />} />
|
||||||
|
<Route path="equipment-capacity" element={<EquipmentCapacityPage />} />
|
||||||
|
<Route path="product-info" element={<ProductInfoPage />} />
|
||||||
|
<Route path="product-equipment-load" element={<ProductEquipmentLoadPage />} />
|
||||||
|
<Route path="schedule" element={<SchedulePage />} />
|
||||||
|
<Route path="capacity-evaluation" element={<CapacityEvaluationPage />} />
|
||||||
|
<Route path="realtime-monitor" element={<RealtimeMonitorPage />} />
|
||||||
|
</Routes>
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const App: React.FC = () => (
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#1677ff',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Menu: {
|
||||||
|
itemSelectedBg: '#1677ff',
|
||||||
|
itemSelectedColor: '#fff',
|
||||||
|
itemHoverBg: '#f5f5f5',
|
||||||
|
groupTitleColor: '#8c8c8c',
|
||||||
|
groupTitleFontSize: 12,
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
Table: {
|
||||||
|
headerBg: '#fafafa',
|
||||||
|
headerColor: '#1a1a1a',
|
||||||
|
rowHoverBg: '#fafafa',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppLayout />
|
||||||
|
</ConfigProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/bexapi',
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 系统参数
|
||||||
|
export const sysParameterApi = {
|
||||||
|
list: () => api.get('/sys-parameters/'),
|
||||||
|
get: (id: number) => api.get(`/sys-parameters/${id}`),
|
||||||
|
create: (data: any) => api.post('/sys-parameters/', data),
|
||||||
|
update: (id: number, data: any) => api.put(`/sys-parameters/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/sys-parameters/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备产能
|
||||||
|
export const equipmentApi = {
|
||||||
|
list: (params?: any) => api.get('/equipment-capacity/', { params }),
|
||||||
|
options: () => api.get('/equipment-capacity/options'),
|
||||||
|
get: (id: number) => api.get(`/equipment-capacity/${id}`),
|
||||||
|
create: (data: any) => api.post('/equipment-capacity/', data),
|
||||||
|
update: (id: number, data: any) => api.put(`/equipment-capacity/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/equipment-capacity/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 品号信息
|
||||||
|
export const productApi = {
|
||||||
|
list: () => api.get('/product-info/'),
|
||||||
|
options: () => api.get('/product-info/options'),
|
||||||
|
get: (id: number) => api.get(`/product-info/${id}`),
|
||||||
|
create: (data: any) => api.post('/product-info/', data),
|
||||||
|
update: (id: number, data: any) => api.put(`/product-info/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/product-info/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 品号设备产能负荷
|
||||||
|
export const productLoadApi = {
|
||||||
|
list: (params?: any) => api.get('/product-equipment-load/', { params }),
|
||||||
|
get: (id: number) => api.get(`/product-equipment-load/${id}`),
|
||||||
|
create: (data: any) => api.post('/product-equipment-load/', data),
|
||||||
|
update: (id: number, data: any) => api.put(`/product-equipment-load/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/product-equipment-load/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 品号设备排产明细
|
||||||
|
export const scheduleApi = {
|
||||||
|
list: (params?: any) => api.get('/schedule/', { params }),
|
||||||
|
get: (id: number) => api.get(`/schedule/${id}`),
|
||||||
|
create: (data: any) => api.post('/schedule/', data),
|
||||||
|
update: (id: number, data: any) => api.put(`/schedule/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/schedule/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备实时产能
|
||||||
|
export const realtimeApi = {
|
||||||
|
list: (params?: any) => api.get('/equipment-realtime/', { params }),
|
||||||
|
summary: () => api.get('/equipment-realtime/summary'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ModuleConfig } from '../registry';
|
||||||
|
|
||||||
|
export const moduleConfig: ModuleConfig = {
|
||||||
|
id: 'bexell-scheduling-ai',
|
||||||
|
name: '试产排程评估及优化',
|
||||||
|
description: 'BEXELL 试产排程评估及优化系统:实时产能监控/产能负荷/排产明细/产能评估',
|
||||||
|
icon: 'CalendarClock',
|
||||||
|
path: '/bexell-scheduling-ai',
|
||||||
|
accentColor: '#1677ff',
|
||||||
|
accentColorLight: '#003088',
|
||||||
|
gradient: 'linear-gradient(135deg, #001533, #002266, #003399)',
|
||||||
|
gradientLight: 'linear-gradient(135deg, #e8f0ff, #dbe7ff, #c8d8ff)',
|
||||||
|
tags: ['试产', '排程', '产能'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
import { Spin } from 'antd'
|
||||||
|
|
||||||
|
const IFRAME_URL = 'https://kai-skc.apps.digiwincloud.com.cn/sso-login?routerLink=/assistant/web/kpedmt3i6ces31f2&userToken='
|
||||||
|
|
||||||
|
|
||||||
|
const CapacityEvaluationPage: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
|
|
||||||
|
// 从 sessionStorage 读取 userToken,拼接到 iframe URL
|
||||||
|
const userToken = sessionStorage.getItem('userToken') || ''
|
||||||
|
const iframeSrc = userToken ? `${IFRAME_URL}${encodeURIComponent(userToken)}` : IFRAME_URL
|
||||||
|
|
||||||
|
const handleIframeLoad = () => {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
{loading && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
zIndex: 10
|
||||||
|
}}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={iframeSrc}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
|
title="产能评估"
|
||||||
|
onLoad={handleIframeLoad}
|
||||||
|
allow="clipboard-read; clipboard-write; fullscreen"
|
||||||
|
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-top-navigation allow-top-navigation-by-user-activation"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CapacityEvaluationPage
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Table, Button, Modal, Form, Input, InputNumber, Space, message, Popconfirm, Select, Card } from 'antd'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
|
import { equipmentApi } from '../api'
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ label: '冲压', value: '冲压' },
|
||||||
|
{ label: '表面', value: '表面' },
|
||||||
|
{ label: '检测', value: '检测' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const EquipmentCapacityPage: React.FC = () => {
|
||||||
|
const [data, setData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const fetchData = async () => { setLoading(true); const res = await equipmentApi.list(); setData(res.data); setLoading(false) }
|
||||||
|
useEffect(() => { fetchData() }, [])
|
||||||
|
|
||||||
|
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true) }
|
||||||
|
const handleEdit = (record: any) => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }
|
||||||
|
const handleDelete = async (id: number) => { await equipmentApi.delete(id); message.success('删除成功'); fetchData() }
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) { await equipmentApi.update(editingId, values); message.success('更新成功') }
|
||||||
|
else { await equipmentApi.create(values); message.success('新增成功') }
|
||||||
|
setModalOpen(false); fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '设备编号', dataIndex: 'equipment_code' },
|
||||||
|
{ title: '设备名称', dataIndex: 'equipment_name' },
|
||||||
|
{ title: '设备类型', dataIndex: 'equipment_type' },
|
||||||
|
{ title: '极限产能(H)', dataIndex: 'max_capacity', align: 'right' as const },
|
||||||
|
{ title: '标准产能(H)', dataIndex: 'standard_capacity', align: 'right' as const },
|
||||||
|
{ title: '最小开机(H)', dataIndex: 'min_startup', align: 'right' as const },
|
||||||
|
{ title: '换线成本系数', dataIndex: 'line_change_cost', align: 'right' as const },
|
||||||
|
{
|
||||||
|
title: '操作', key: 'action', width: 160,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增设备</Button>
|
||||||
|
</div>
|
||||||
|
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: 1000 }} />
|
||||||
|
<Modal title={editingId ? '编辑设备' : '新增设备'} open={modalOpen} onOk={handleSave} onCancel={() => setModalOpen(false)} width={600}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="equipment_code" label="设备编号" rules={[{ required: true }]}>
|
||||||
|
<Input disabled={!!editingId} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="equipment_name" label="设备名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="equipment_type" label="设备类型" rules={[{ required: true }]}>
|
||||||
|
<Select options={typeOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_capacity" label="极限产能(H)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="standard_capacity" label="标准产能(H)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="min_startup" label="最小开机(H)" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="line_change_cost" label="换线成本系数" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EquipmentCapacityPage
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Table, Button, Modal, Form, Input, InputNumber, Space, message, Popconfirm, Card } from 'antd'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons'
|
||||||
|
import { productLoadApi, productApi, equipmentApi } from '../api'
|
||||||
|
|
||||||
|
const SelectorModal: React.FC<{
|
||||||
|
open: boolean; title: string; columns: any[]; dataSource: any[]
|
||||||
|
onSelect: (record: any) => void; onCancel: () => void
|
||||||
|
}> = ({ open, title, columns, dataSource, onSelect, onCancel }) => (
|
||||||
|
<Modal title={title} open={open} onCancel={onCancel} footer={null} width={640}>
|
||||||
|
<Table columns={columns} dataSource={dataSource}
|
||||||
|
rowKey={(r) => r.product_code || r.equipment_code}
|
||||||
|
size="small" pagination={{ pageSize: 10 }}
|
||||||
|
onRow={(record) => ({ onClick: () => onSelect(record), style: { cursor: 'pointer' } })}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ProductEquipmentLoadPage: React.FC = () => {
|
||||||
|
const [data, setData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [productSelectorOpen, setProductSelectorOpen] = useState(false)
|
||||||
|
const [equipmentSelectorOpen, setEquipmentSelectorOpen] = useState(false)
|
||||||
|
const [productOptions, setProductOptions] = useState<any[]>([])
|
||||||
|
const [equipmentOptions, setEquipmentOptions] = useState<any[]>([])
|
||||||
|
|
||||||
|
const fetchData = async () => { setLoading(true); const res = await productLoadApi.list(); setData(res.data); setLoading(false) }
|
||||||
|
useEffect(() => { fetchData() }, [])
|
||||||
|
|
||||||
|
const loadProductOptions = async () => { const res = await productApi.options(); setProductOptions(res.data) }
|
||||||
|
const loadEquipmentOptions = async () => { const res = await equipmentApi.options(); setEquipmentOptions(res.data) }
|
||||||
|
|
||||||
|
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true); loadProductOptions(); loadEquipmentOptions() }
|
||||||
|
const handleEdit = (record: any) => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }
|
||||||
|
const handleDelete = async (id: number) => { await productLoadApi.delete(id); message.success('删除成功'); fetchData() }
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
await productLoadApi.update(editingId, { standard_capacity: values.standard_capacity })
|
||||||
|
message.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await productLoadApi.create(values); message.success('新增成功')
|
||||||
|
}
|
||||||
|
setModalOpen(false); fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectProduct = (record: any) => {
|
||||||
|
form.setFieldsValue({ product_code: record.product_code, product_name: record.product_name, specification: record.specification })
|
||||||
|
setProductSelectorOpen(false)
|
||||||
|
}
|
||||||
|
const handleSelectEquipment = (record: any) => {
|
||||||
|
form.setFieldsValue({ equipment_code: record.equipment_code, equipment_name: record.equipment_name })
|
||||||
|
setEquipmentSelectorOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '品号', dataIndex: 'product_code' },
|
||||||
|
{ title: '品名', dataIndex: 'product_name' },
|
||||||
|
{ title: '规格', dataIndex: 'specification' },
|
||||||
|
{ title: '设备编号', dataIndex: 'equipment_code' },
|
||||||
|
{ title: '设备名称', dataIndex: 'equipment_name' },
|
||||||
|
{ title: '标准产能', dataIndex: 'standard_capacity', align: 'right' as const },
|
||||||
|
{
|
||||||
|
title: '操作', key: 'action', width: 160,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增产能负荷</Button>
|
||||||
|
</div>
|
||||||
|
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: 1000 }} />
|
||||||
|
|
||||||
|
<Modal title={editingId ? '编辑产能负荷' : '新增产能负荷'} open={modalOpen} onOk={handleSave} onCancel={() => setModalOpen(false)} width={640}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="品号(点击开窗选取)">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="product_code" noStyle rules={[{ required: true, message: '请选择品号' }]}>
|
||||||
|
<Input placeholder="请选择品号" readOnly style={{ width: '30%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="product_name" noStyle>
|
||||||
|
<Input placeholder="品名" readOnly style={{ width: '35%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="specification" noStyle>
|
||||||
|
<Input placeholder="规格" readOnly style={{ width: '35%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space.Compact>
|
||||||
|
{!editingId && (
|
||||||
|
<Button type="dashed" icon={<SearchOutlined />} onClick={() => setProductSelectorOpen(true)} style={{ marginTop: 4 }}>
|
||||||
|
开窗选取品号
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="设备(点击开窗选取)">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="equipment_code" noStyle rules={[{ required: true, message: '请选择设备' }]}>
|
||||||
|
<Input placeholder="请选择设备编号" readOnly style={{ width: '40%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="equipment_name" noStyle>
|
||||||
|
<Input placeholder="设备名称" readOnly style={{ width: '60%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space.Compact>
|
||||||
|
{!editingId && (
|
||||||
|
<Button type="dashed" icon={<SearchOutlined />} onClick={() => setEquipmentSelectorOpen(true)} style={{ marginTop: 4 }}>
|
||||||
|
开窗选取设备
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="standard_capacity" label="标准产能" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<SelectorModal open={productSelectorOpen} title="选取品号"
|
||||||
|
columns={[{ title: '品号', dataIndex: 'product_code' }, { title: '品名', dataIndex: 'product_name' }, { title: '规格', dataIndex: 'specification' }]}
|
||||||
|
dataSource={productOptions} onSelect={handleSelectProduct} onCancel={() => setProductSelectorOpen(false)} />
|
||||||
|
<SelectorModal open={equipmentSelectorOpen} title="选取设备"
|
||||||
|
columns={[{ title: '设备编号', dataIndex: 'equipment_code' }, { title: '设备名称', dataIndex: 'equipment_name' }, { title: '设备类型', dataIndex: 'equipment_type' }]}
|
||||||
|
dataSource={equipmentOptions} onSelect={handleSelectEquipment} onCancel={() => setEquipmentSelectorOpen(false)} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductEquipmentLoadPage
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Table, Button, Modal, Form, Input, Space, message, Popconfirm, Card } from 'antd'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
|
import { productApi } from '../api'
|
||||||
|
|
||||||
|
const ProductInfoPage: React.FC = () => {
|
||||||
|
const [data, setData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const fetchData = async () => { setLoading(true); const res = await productApi.list(); setData(res.data); setLoading(false) }
|
||||||
|
useEffect(() => { fetchData() }, [])
|
||||||
|
|
||||||
|
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true) }
|
||||||
|
const handleEdit = (record: any) => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }
|
||||||
|
const handleDelete = async (id: number) => { await productApi.delete(id); message.success('删除成功'); fetchData() }
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) { await productApi.update(editingId, values); message.success('更新成功') }
|
||||||
|
else { await productApi.create(values); message.success('新增成功') }
|
||||||
|
setModalOpen(false); fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '品号', dataIndex: 'product_code' },
|
||||||
|
{ title: '品名', dataIndex: 'product_name' },
|
||||||
|
{ title: '规格', dataIndex: 'specification' },
|
||||||
|
{
|
||||||
|
title: '操作', key: 'action', width: 160,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增品号</Button>
|
||||||
|
</div>
|
||||||
|
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} />
|
||||||
|
<Modal title={editingId ? '编辑品号' : '新增品号'} open={modalOpen} onOk={handleSave} onCancel={() => setModalOpen(false)}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="product_code" label="品号" rules={[{ required: true }]}>
|
||||||
|
<Input disabled={!!editingId} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="product_name" label="品名" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="specification" label="规格" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductInfoPage
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Table, Card, Row, Col, Select, Button, Tag } from 'antd'
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
FireFilled,
|
||||||
|
WarningFilled,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
CloseCircleFilled,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { realtimeApi } from '../api'
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = { '冲压': 'blue', '表面': 'orange', '检测': 'green' }
|
||||||
|
|
||||||
|
function getStatus(rate: number): { label: string; color: string; tagColor: string } {
|
||||||
|
if (rate <= 0) return { label: '空闲', color: '#bfbfbf', tagColor: 'default' }
|
||||||
|
if (rate < 100) return { label: '正常', color: '#1677ff', tagColor: 'green' }
|
||||||
|
if (rate === 100) return { label: '满载', color: '#fa8c16', tagColor: 'orange' }
|
||||||
|
return { label: '超负荷', color: '#ff4d4f', tagColor: 'red' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const RealtimeMonitorPage: React.FC = () => {
|
||||||
|
const [data, setData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [filterType, setFilterType] = useState<string | undefined>(undefined)
|
||||||
|
const [stats, setStats] = useState({ total: 0, normal: 0, full: 0, overload: 0, idle: 0 })
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const listRes = await realtimeApi.list(filterType ? { equipment_type: filterType } : {})
|
||||||
|
const rows = listRes.data
|
||||||
|
setData(rows)
|
||||||
|
|
||||||
|
let normal = 0, full = 0, overload = 0, idle = 0
|
||||||
|
rows.forEach((r: any) => {
|
||||||
|
const rate = Number(r.standard_capacity_h) > 0
|
||||||
|
? (Number(r.current_load_h) / Number(r.standard_capacity_h)) * 100
|
||||||
|
: 0
|
||||||
|
if (rate <= 0) idle++
|
||||||
|
else if (rate < 100) normal++
|
||||||
|
else if (rate === 100) full++
|
||||||
|
else overload++
|
||||||
|
})
|
||||||
|
setStats({ total: rows.length, normal, full, overload, idle })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [filterType])
|
||||||
|
|
||||||
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '设备类型',
|
||||||
|
dataIndex: 'equipment_type',
|
||||||
|
key: 'equipment_type',
|
||||||
|
render: (v: string) => <Tag color={typeColors[v]}>{v}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '设备编号', dataIndex: 'equipment_code', key: 'equipment_code' },
|
||||||
|
{ title: '设备名称', dataIndex: 'equipment_name', key: 'equipment_name' },
|
||||||
|
{ title: '极限产能(H)', dataIndex: 'max_capacity_h', key: 'max_capacity_h', align: 'right' as const },
|
||||||
|
{ title: '标准产能(H)', dataIndex: 'standard_capacity_h', key: 'standard_capacity_h', align: 'right' as const },
|
||||||
|
{ title: '当前负荷(H)', dataIndex: 'current_load_h', key: 'current_load_h', align: 'right' as const },
|
||||||
|
{
|
||||||
|
title: '负荷率',
|
||||||
|
key: 'load_rate',
|
||||||
|
width: 220,
|
||||||
|
render: (_: any, record: any) => {
|
||||||
|
const rate = Number(record.standard_capacity_h) > 0
|
||||||
|
? Math.round((Number(record.current_load_h) / Number(record.standard_capacity_h)) * 100)
|
||||||
|
: 0
|
||||||
|
const status = getStatus(rate)
|
||||||
|
|
||||||
|
if (rate >= 100) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, height: 8, background: '#f5f5f5', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: '100%', height: '100%', background: '#ff4d4f', borderRadius: 4 }} />
|
||||||
|
</div>
|
||||||
|
<CloseCircleFilled style={{ color: '#ff4d4f', fontSize: 14 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, height: 8, background: '#f5f5f5', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(rate, 100)}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: rate === 0 ? '#d9d9d9' : '#1677ff',
|
||||||
|
borderRadius: 4,
|
||||||
|
transition: 'width 0.3s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: status.color, fontWeight: 600, minWidth: 40, textAlign: 'right' }}>
|
||||||
|
{rate}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
render: (_: any, record: any) => {
|
||||||
|
const rate = Number(record.standard_capacity_h) > 0
|
||||||
|
? (Number(record.current_load_h) / Number(record.standard_capacity_h)) * 100 : 0
|
||||||
|
const status = getStatus(rate)
|
||||||
|
return <Tag color={status.tagColor}>{status.label}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 统计卡片行 */}
|
||||||
|
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||||
|
<Col span={4}>
|
||||||
|
<Card style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: '#f5f5f5', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CheckCircleOutlined style={{ fontSize: 20, color: '#595959' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#8c8c8c', fontSize: 13 }}>设备总数</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#262626' }}>{stats.total}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Card style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: '#f6ffed', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CheckCircleFilled style={{ fontSize: 20, color: '#52c41a' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#8c8c8c', fontSize: 13 }}>正常</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#52c41a' }}>{stats.normal}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Card style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: '#fff7e6', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<FireFilled style={{ fontSize: 20, color: '#fa8c16' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#8c8c8c', fontSize: 13 }}>满载</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#fa8c16' }}>{stats.full}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Card style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: '#fff1f0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<WarningFilled style={{ fontSize: 20, color: '#ff4d4f' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#8c8c8c', fontSize: 13 }}>超负荷</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#ff4d4f' }}>{stats.overload}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Card style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: '#fafafa', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<PauseCircleOutlined style={{ fontSize: 20, color: '#bfbfbf' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#8c8c8c', fontSize: 13 }}>空闲</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#bfbfbf' }}>{stats.idle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 数据表格卡片 */}
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 600 }}>设备产能明细</span>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="按设备类型筛选"
|
||||||
|
style={{ width: 180 }}
|
||||||
|
options={[
|
||||||
|
{ label: '冲压', value: '冲压' },
|
||||||
|
{ label: '表面', value: '表面' },
|
||||||
|
{ label: '检测', value: '检测' },
|
||||||
|
]}
|
||||||
|
onChange={(v) => setFilterType(v)}
|
||||||
|
value={filterType}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={fetchData}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
刷新数据
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="equipment_code"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
size="middle"
|
||||||
|
rowClassName={(record) => {
|
||||||
|
const rate = Number(record.standard_capacity_h) > 0
|
||||||
|
? (Number(record.current_load_h) / Number(record.standard_capacity_h)) * 100 : 0
|
||||||
|
return rate > 100 ? 'row-overload' : ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.row-overload td { background: #fff2f0 !important; }
|
||||||
|
.ant-card-head { border-bottom: 1px solid #f5f5f5 !important; }
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RealtimeMonitorPage
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Table, Button, Modal, Form, Input, InputNumber, Space, message, Popconfirm, Card } from 'antd'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons'
|
||||||
|
import { scheduleApi, productApi, equipmentApi } from '../api'
|
||||||
|
|
||||||
|
const SelectorModal: React.FC<{
|
||||||
|
open: boolean; title: string; columns: any[]; dataSource: any[]
|
||||||
|
onSelect: (record: any) => void; onCancel: () => void
|
||||||
|
}> = ({ open, title, columns, dataSource, onSelect, onCancel }) => (
|
||||||
|
<Modal title={title} open={open} onCancel={onCancel} footer={null} width={640}>
|
||||||
|
<Table columns={columns} dataSource={dataSource}
|
||||||
|
rowKey={(r) => r.product_code || r.equipment_code}
|
||||||
|
size="small" pagination={{ pageSize: 10 }}
|
||||||
|
onRow={(record) => ({ onClick: () => onSelect(record), style: { cursor: 'pointer' } })}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SchedulePage: React.FC = () => {
|
||||||
|
const [data, setData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [productSelectorOpen, setProductSelectorOpen] = useState(false)
|
||||||
|
const [equipmentSelectorOpen, setEquipmentSelectorOpen] = useState(false)
|
||||||
|
const [productOptions, setProductOptions] = useState<any[]>([])
|
||||||
|
const [equipmentOptions, setEquipmentOptions] = useState<any[]>([])
|
||||||
|
|
||||||
|
const fetchData = async () => { setLoading(true); const res = await scheduleApi.list(); setData(res.data); setLoading(false) }
|
||||||
|
useEffect(() => { fetchData() }, [])
|
||||||
|
|
||||||
|
const loadProductOptions = async () => { const res = await productApi.options(); setProductOptions(res.data) }
|
||||||
|
const loadEquipmentOptions = async () => { const res = await equipmentApi.options(); setEquipmentOptions(res.data) }
|
||||||
|
|
||||||
|
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true); loadProductOptions(); loadEquipmentOptions() }
|
||||||
|
const handleEdit = (record: any) => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }
|
||||||
|
const handleDelete = async (id: number) => { await scheduleApi.delete(id); message.success('删除成功'); fetchData() }
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
await scheduleApi.update(editingId, { quantity: values.quantity, occupied_capacity: values.occupied_capacity })
|
||||||
|
message.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await scheduleApi.create(values); message.success('新增成功')
|
||||||
|
}
|
||||||
|
setModalOpen(false); fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectProduct = (record: any) => {
|
||||||
|
form.setFieldsValue({ product_code: record.product_code, product_name: record.product_name, specification: record.specification })
|
||||||
|
setProductSelectorOpen(false)
|
||||||
|
}
|
||||||
|
const handleSelectEquipment = (record: any) => {
|
||||||
|
form.setFieldsValue({ equipment_code: record.equipment_code, equipment_name: record.equipment_name, equipment_type: record.equipment_type })
|
||||||
|
setEquipmentSelectorOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '排产ID', dataIndex: 'schedule_id' },
|
||||||
|
{ title: '设备编号', dataIndex: 'equipment_code' },
|
||||||
|
{ title: '设备名称', dataIndex: 'equipment_name' },
|
||||||
|
{ title: '设备类型', dataIndex: 'equipment_type' },
|
||||||
|
{ title: '品号', dataIndex: 'product_code' },
|
||||||
|
{ title: '品名', dataIndex: 'product_name' },
|
||||||
|
{ title: '规格', dataIndex: 'specification' },
|
||||||
|
{ title: '数量', dataIndex: 'quantity', align: 'right' as const },
|
||||||
|
{ title: '占用产能', dataIndex: 'occupied_capacity', align: 'right' as const },
|
||||||
|
{
|
||||||
|
title: '操作', key: 'action', width: 160,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增排产</Button>
|
||||||
|
</div>
|
||||||
|
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: 1200 }} />
|
||||||
|
|
||||||
|
<Modal title={editingId ? '编辑排产' : '新增排产'} open={modalOpen} onOk={handleSave} onCancel={() => setModalOpen(false)} width={680}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="品号(点击开窗选取)">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="product_code" noStyle rules={[{ required: true, message: '请选择品号' }]}>
|
||||||
|
<Input placeholder="请选择品号" readOnly style={{ width: '30%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="product_name" noStyle>
|
||||||
|
<Input placeholder="品名" readOnly style={{ width: '35%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="specification" noStyle>
|
||||||
|
<Input placeholder="规格" readOnly style={{ width: '35%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space.Compact>
|
||||||
|
{!editingId && (
|
||||||
|
<Button type="dashed" icon={<SearchOutlined />} onClick={() => { setProductSelectorOpen(true); loadProductOptions() }} style={{ marginTop: 4 }}>
|
||||||
|
开窗选取品号
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="设备(点击开窗选取)">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="equipment_code" noStyle rules={[{ required: true, message: '请选择设备' }]}>
|
||||||
|
<Input placeholder="请选择设备编号" readOnly style={{ width: '30%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="equipment_name" noStyle>
|
||||||
|
<Input placeholder="设备名称" readOnly style={{ width: '35%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="equipment_type" noStyle>
|
||||||
|
<Input placeholder="设备类型" readOnly style={{ width: '35%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space.Compact>
|
||||||
|
{!editingId && (
|
||||||
|
<Button type="dashed" icon={<SearchOutlined />} onClick={() => { setEquipmentSelectorOpen(true); loadEquipmentOptions() }} style={{ marginTop: 4 }}>
|
||||||
|
开窗选取设备
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="quantity" label="数量" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="occupied_capacity" label="占用产能" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<SelectorModal open={productSelectorOpen} title="选取品号"
|
||||||
|
columns={[{ title: '品号', dataIndex: 'product_code' }, { title: '品名', dataIndex: 'product_name' }, { title: '规格', dataIndex: 'specification' }]}
|
||||||
|
dataSource={productOptions} onSelect={handleSelectProduct} onCancel={() => setProductSelectorOpen(false)} />
|
||||||
|
<SelectorModal open={equipmentSelectorOpen} title="选取设备"
|
||||||
|
columns={[{ title: '设备编号', dataIndex: 'equipment_code' }, { title: '设备名称', dataIndex: 'equipment_name' }, { title: '设备类型', dataIndex: 'equipment_type' }]}
|
||||||
|
dataSource={equipmentOptions} onSelect={handleSelectEquipment} onCancel={() => setEquipmentSelectorOpen(false)} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SchedulePage
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Table, Button, Modal, Form, Input, Space, message, Popconfirm, Card } from 'antd'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
|
import { sysParameterApi } from '../api'
|
||||||
|
|
||||||
|
const SysParameterPage: React.FC = () => {
|
||||||
|
const [data, setData] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const fetchData = async () => { setLoading(true); const res = await sysParameterApi.list(); setData(res.data); setLoading(false) }
|
||||||
|
useEffect(() => { fetchData() }, [])
|
||||||
|
|
||||||
|
const handleAdd = () => { setEditingId(null); form.resetFields(); setModalOpen(true) }
|
||||||
|
const handleEdit = (record: any) => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }
|
||||||
|
const handleDelete = async (id: number) => { await sysParameterApi.delete(id); message.success('删除成功'); fetchData() }
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) { await sysParameterApi.update(editingId, values); message.success('更新成功') }
|
||||||
|
else { await sysParameterApi.create(values); message.success('新增成功') }
|
||||||
|
setModalOpen(false); fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '参数编码', dataIndex: 'param_code', key: 'param_code' },
|
||||||
|
{ title: '参数描述', dataIndex: 'param_name', key: 'param_name' },
|
||||||
|
{ title: '参数值', dataIndex: 'param_value', key: 'param_value' },
|
||||||
|
{
|
||||||
|
title: '操作', key: 'action', width: 160,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ borderRadius: 12, boxShadow: '0 1px 2px rgba(0,0,0,0.03)' }}>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增参数</Button>
|
||||||
|
</div>
|
||||||
|
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} pagination={false} />
|
||||||
|
<Modal title={editingId ? '编辑参数' : '新增参数'} open={modalOpen} onOk={handleSave} onCancel={() => setModalOpen(false)}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="param_code" label="参数编码" rules={[{ required: true }]}>
|
||||||
|
<Input disabled={!!editingId} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="param_name" label="参数描述" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="param_value" label="参数值" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SysParameterPage
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { countNodes } from '../core/fingerprint.js';
|
||||||
|
import BomTree from './BomTree';
|
||||||
|
|
||||||
|
export default function BomBrowser({ products }: any) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedId, setSelectedId] = useState<any>(null);
|
||||||
|
const [collapsed, setCollapsed] = useState(new Set<any>());
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const g: Record<string, any[]> = {};
|
||||||
|
for (const p of products) {
|
||||||
|
(g[p.series] = g[p.series] || []).push(p);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}, [products]);
|
||||||
|
|
||||||
|
const selected = products.find((p: any) => p.id === selectedId) || null;
|
||||||
|
const lower = search.toLowerCase();
|
||||||
|
|
||||||
|
function toggleGroup(seriesName: any) {
|
||||||
|
setCollapsed(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(seriesName)) next.delete(seriesName);
|
||||||
|
else next.add(seriesName);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="two-panel">
|
||||||
|
<div className="panel-left">
|
||||||
|
<div className="search-box">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索产品编码或名称..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{Object.entries(groups).map(([seriesName, list]) => {
|
||||||
|
const filtered = list.filter((p: any) =>
|
||||||
|
!search || p.id.toLowerCase().includes(lower) || p.name.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
// 搜索时强制展开
|
||||||
|
const isCollapsed = !search && collapsed.has(seriesName);
|
||||||
|
return (
|
||||||
|
<div key={seriesName} className="product-group">
|
||||||
|
<div
|
||||||
|
className={`product-group-title ${isCollapsed ? 'collapsed' : ''}`}
|
||||||
|
onClick={() => !search && toggleGroup(seriesName)}
|
||||||
|
>
|
||||||
|
<span className="product-group-arrow">{isCollapsed ? '▸' : '▾'}</span>
|
||||||
|
<span className="product-group-name">{seriesName}</span>
|
||||||
|
<span className="product-group-count">{filtered.length}</span>
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && filtered.map((p: any) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className={`product-item ${selectedId === p.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedId(p.id)}
|
||||||
|
>
|
||||||
|
<span>{p.id}</span>
|
||||||
|
<span className="product-meta">{countNodes(p.bom)}节点</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel-right">
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 12, fontWeight: 600, fontSize: 15 }}>{selected.name}</div>
|
||||||
|
<BomTree root={selected.bom} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">📦</div>
|
||||||
|
<div className="empty-state-text">请从左侧选择一个产品查看 BOM</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { computeFingerprint } from '../core/fingerprint.js';
|
||||||
|
|
||||||
|
function getHighlightSet(highlightFingerprint: any, highlightSet: any) {
|
||||||
|
if (highlightSet) return highlightSet;
|
||||||
|
if (highlightFingerprint) return new Set([highlightFingerprint]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeFp(node: any) { return computeFingerprint(node); }
|
||||||
|
|
||||||
|
function containsHighlight(node: any, set: any) {
|
||||||
|
if (set.has(nodeFp(node))) return true;
|
||||||
|
if (node.children) return node.children.some((c: any) => containsHighlight(c, set));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeNode({ node, set, focus }: any) {
|
||||||
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
|
if (focus && set && set.size) return !containsHighlight(node, set);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
|
const isHighlighted = set.has(nodeFp(node));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tree-node">
|
||||||
|
<div className={`tree-node-row ${isHighlighted ? 'highlight-reuse' : ''}`}>
|
||||||
|
{hasChildren ? (
|
||||||
|
<span className="tree-toggle" onClick={() => setCollapsed(!collapsed)}>
|
||||||
|
{collapsed ? '\u25B6' : '\u25BC'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="tree-toggle" style={{ visibility: 'hidden' }}>{'\u25B6'}</span>
|
||||||
|
)}
|
||||||
|
<span className="tree-col tree-col-code">{node.part.code}</span>
|
||||||
|
<span className="tree-col tree-col-name">
|
||||||
|
{node.part.name}
|
||||||
|
{isHighlighted && <span className="badge badge-reuse">命中</span>}
|
||||||
|
</span>
|
||||||
|
<span className="tree-col tree-col-spec">{node.part.spec || ''}</span>
|
||||||
|
<span className="tree-col tree-col-qty">{node.qty}</span>
|
||||||
|
<span className="tree-col tree-col-unit">{node.part.unit || ''}</span>
|
||||||
|
</div>
|
||||||
|
{hasChildren && !collapsed && (
|
||||||
|
<div className="tree-children">
|
||||||
|
{node.children.map((child: any, i: number) => (
|
||||||
|
<TreeNode key={i} node={child} set={set} focus={focus} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BomTree({ root, highlightFingerprint = null, highlightSet = null, showSpec = true, focus = false, height = null }: any) {
|
||||||
|
const set = getHighlightSet(highlightFingerprint, highlightSet) || new Set<any>();
|
||||||
|
return (
|
||||||
|
<div className="tree-scroll" style={height ? { maxHeight: height } : undefined}>
|
||||||
|
<div className="bom-tree">
|
||||||
|
<div className="tree-header">
|
||||||
|
<span style={{ width: 18 }}></span>
|
||||||
|
<span className="tree-col tree-col-code">物料编码</span>
|
||||||
|
<span className="tree-col tree-col-name">名称</span>
|
||||||
|
{showSpec && <span className="tree-col tree-col-spec">规格</span>}
|
||||||
|
<span className="tree-col tree-col-qty">数量</span>
|
||||||
|
<span className="tree-col tree-col-unit">单位</span>
|
||||||
|
</div>
|
||||||
|
<TreeNode node={root} set={set} focus={focus} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ConfirmModal({ open, title, message, confirmText = '确定', danger = true, onConfirm, onCancel }: any) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel(); };
|
||||||
|
if (open) window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, onCancel]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="confirm-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
|
||||||
|
<div className="confirm-modal">
|
||||||
|
<div className="confirm-modal-icon">{danger ? '\u26A0' : '\u2139'}</div>
|
||||||
|
<div className="confirm-modal-title">{title}</div>
|
||||||
|
<div className="confirm-modal-message">{message}</div>
|
||||||
|
<div className="confirm-modal-actions">
|
||||||
|
<button className="btn" onClick={onCancel}>取消</button>
|
||||||
|
<button className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`} onClick={onConfirm}>{confirmText}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { IconX } from './Icons';
|
||||||
|
|
||||||
|
// 使用 Portal 渲染到 body,避免被父级层叠上下文/导航条遮挡
|
||||||
|
export default function Drawer({ open, title, onClose, children, width = 540 }: any) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
{/* 遮罩:覆盖全屏(含导航条),点击任意非抽屉区域关闭 */}
|
||||||
|
{open && <div className="drawer-overlay" onClick={onClose} />}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`drawer ${open ? 'drawer-open' : 'drawer-closed'}`}
|
||||||
|
style={{ width }}
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
<div className="drawer-header">
|
||||||
|
<span className="drawer-title">{title}</span>
|
||||||
|
<button type="button" className="drawer-close" onClick={onClose} aria-label="关闭" title="关闭">
|
||||||
|
<IconX />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="drawer-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Icons.tsx - SVG 图标系统(stroke 风格,1.5px 线宽)
|
||||||
|
const base = {
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
width: '1em',
|
||||||
|
height: '1em',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const IconBox = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M21 8l-9-5-9 5 9 5 9-5z"/><path d="M3 8v8l9 5 9-5V8"/><path d="M12 13v8"/></svg>
|
||||||
|
);
|
||||||
|
export const IconSearch = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||||
|
);
|
||||||
|
export const IconNetwork = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><circle cx="12" cy="5" r="2.5"/><circle cx="5" cy="19" r="2.5"/><circle cx="19" cy="19" r="2.5"/><path d="M12 7.5v4M12 11.5l-5.5 5M12 11.5l5.5 5"/></svg>
|
||||||
|
);
|
||||||
|
export const IconCompare = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M8 3v18M16 3v18"/><path d="M3 8h5M3 16h5M16 8h5M16 12h5"/></svg>
|
||||||
|
);
|
||||||
|
export const IconLayers = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M12 2l10 6-10 6L2 8l10-6z"/><path d="M2 13l10 6 10-6"/><path d="M2 17l10 6 10-6"/></svg>
|
||||||
|
);
|
||||||
|
export const IconRefresh = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/></svg>
|
||||||
|
);
|
||||||
|
export const IconChevronDown = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
);
|
||||||
|
export const IconChevronRight = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M9 6l6 6-6 6"/></svg>
|
||||||
|
);
|
||||||
|
export const IconCheck = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
);
|
||||||
|
export const IconX = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||||
|
);
|
||||||
|
export const IconTrash = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||||
|
);
|
||||||
|
export const IconPlus = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
);
|
||||||
|
export const IconAlert = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M12 3l10 18H2L12 3z"/><path d="M12 10v5M12 18.5v.5"/></svg>
|
||||||
|
);
|
||||||
|
export const IconInfo = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7.5v.5"/></svg>
|
||||||
|
);
|
||||||
|
export const IconEye = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
);
|
||||||
|
export const IconTarget = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></svg>
|
||||||
|
);
|
||||||
|
export const IconSparkle = (p: any) => (
|
||||||
|
<svg {...base} viewBox="0 0 24 24" {...p}><path d="M12 3l1.9 5.6L19.5 10l-5.6 1.9L12 17.5l-1.9-5.6L4.5 10l5.6-1.9L12 3z"/></svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { IconBox } from './Icons';
|
||||||
|
|
||||||
|
// 默认加载阶段定义(向后兼容)
|
||||||
|
const DEFAULT_STAGES = [
|
||||||
|
{ key: 'download', label: '下载数据', icon: '↓' },
|
||||||
|
{ key: 'parse', label: '解析 JSON', icon: '{}' },
|
||||||
|
{ key: 'fingerprint',label: '指纹计算', icon: '#' },
|
||||||
|
{ key: 'filter', label: '阈值过滤', icon: '⚙' },
|
||||||
|
{ key: 'dedup', label: '子路径去重', icon: '⊕' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtBytes(bytes: number) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
||||||
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ms: number) {
|
||||||
|
if (ms < 1000) return ms.toFixed(0) + 'ms';
|
||||||
|
return (ms / 1000).toFixed(1) + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageRow({ stage, index, currentStage, stageStatus, stageDetail }: any) {
|
||||||
|
const status = stageStatus[stage.key];
|
||||||
|
const isCurrent = currentStage === stage.key;
|
||||||
|
const isPast = status === 'done';
|
||||||
|
const isPending = !status && !isCurrent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`load-stage-row ${isCurrent ? 'active' : ''} ${isPast ? 'done' : ''} ${isPending ? 'pending' : ''}`}>
|
||||||
|
<div className="load-stage-icon">
|
||||||
|
{isPast ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 6L9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
) : isCurrent ? (
|
||||||
|
<span className="load-stage-spinner" />
|
||||||
|
) : (
|
||||||
|
<span className="load-stage-index">{index + 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="load-stage-info">
|
||||||
|
<span className="load-stage-label">{stage.label}</span>
|
||||||
|
{stageDetail[stage.key] && (
|
||||||
|
<span className="load-stage-detail">{stageDetail[stage.key]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadingScreen({ phase, stageStatus, stageDetail, progress, totalBytes, error, onRetry, fadingOut, stages }: any) {
|
||||||
|
const STAGES = stages || DEFAULT_STAGES;
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const startTime = useRef(performance.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setElapsed(performance.now() - startTime.current);
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
// 计算总体进度
|
||||||
|
const stagesDone = Object.values(stageStatus).filter((s: any) => s === 'done').length;
|
||||||
|
const overallPct = error
|
||||||
|
? 0
|
||||||
|
: progress > 0
|
||||||
|
? Math.min((stagesDone / STAGES.length) * 100 + (progress / STAGES.length), 99.5)
|
||||||
|
: (stagesDone / STAGES.length) * 100;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={`loading-screen ${fadingOut ? 'fading-out' : ''}`}>
|
||||||
|
<div className="loading-card">
|
||||||
|
<div className="loading-error-icon">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4M12 16v.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="loading-error-title">数据加载失败</div>
|
||||||
|
<div className="loading-error-msg">{error}</div>
|
||||||
|
<button className="btn btn-primary loading-retry-btn" onClick={onRetry}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`loading-screen ${fadingOut ? 'fading-out' : ''}`}>
|
||||||
|
<div className="loading-card">
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="loading-header">
|
||||||
|
<span className="loading-logo"><IconBox /></span>
|
||||||
|
<span className="loading-title">BOM 可复用组件挖掘分析</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 阶段列表 */}
|
||||||
|
<div className="load-stage-list">
|
||||||
|
{STAGES.map((stage: any, i: number) => (
|
||||||
|
<StageRow
|
||||||
|
key={stage.key}
|
||||||
|
stage={stage}
|
||||||
|
index={i}
|
||||||
|
currentStage={phase}
|
||||||
|
stageStatus={stageStatus}
|
||||||
|
stageDetail={stageDetail}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 总体进度条 */}
|
||||||
|
<div className="load-progress-section">
|
||||||
|
<div className="load-progress-bar-track">
|
||||||
|
<div
|
||||||
|
className="load-progress-bar-fill"
|
||||||
|
style={{ width: `${overallPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="load-progress-meta">
|
||||||
|
<span>{overallPct.toFixed(0)}%</span>
|
||||||
|
<span className="load-elapsed">
|
||||||
|
{totalBytes > 0 && `${fmtBytes(totalBytes)} · `}
|
||||||
|
已耗时 {fmtTime(elapsed)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useMemo, useState, useRef, useCallback } from 'react';
|
||||||
|
import { getScarcityLevel, getWeightedValue } from '../core/mining.js';
|
||||||
|
import { adoptComponent, adoptComponents, getStandardComponents } from '../core/store.js';
|
||||||
|
import { openSidePanel } from './SidePanel';
|
||||||
|
import { toast } from './Toast';
|
||||||
|
import { IconNetwork } from './Icons';
|
||||||
|
import BomTree from './BomTree';
|
||||||
|
|
||||||
|
function SubTree({ node, depth = 0 }: any) {
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.9, paddingLeft: depth * 16 }}>
|
||||||
|
<span style={{ color: 'var(--accent-primary)', fontWeight: 500 }}>{node.part.code}</span>{' '}
|
||||||
|
{node.part.name} <span className="text-muted">x{node.qty}</span>
|
||||||
|
{node.children && node.children.map((c: any, i: number) => <SubTree key={i} node={c} depth={depth + 1} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScarcityBadge({ productCount, totalProducts }: any) {
|
||||||
|
const s = getScarcityLevel(productCount, totalProducts);
|
||||||
|
return (
|
||||||
|
<span className="scarcity-badge" style={{
|
||||||
|
fontSize: 10, padding: '2px 7px', borderRadius: 6,
|
||||||
|
background: s.color + '1a', color: s.color, border: `1px solid ${s.color}33`,
|
||||||
|
fontWeight: 600, whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{s.level}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MiningView({ products, miningResult, setMiningResult, thresholds, setThresholds }: any) {
|
||||||
|
const minCoveragePct = thresholds.minCoveragePct;
|
||||||
|
const minNodes = thresholds.minNodes;
|
||||||
|
const results = miningResult;
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [mining, setMining] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState<any>({});
|
||||||
|
const [checked, setChecked] = useState<any>({});
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
|
||||||
|
const standardFps = useMemo(() => new Set<any>(getStandardComponents().map((c: any) => c.fingerprint)), [results]);
|
||||||
|
|
||||||
|
const minProductCount = Math.max(2, Math.round(products.length * minCoveragePct / 100));
|
||||||
|
|
||||||
|
const runMining = useCallback(() => {
|
||||||
|
// 终止已有 worker
|
||||||
|
if (workerRef.current) workerRef.current.terminate();
|
||||||
|
|
||||||
|
setMining(true);
|
||||||
|
setStatus('后台计算中...');
|
||||||
|
|
||||||
|
const worker = new Worker(new URL('../core/mining-worker.js', import.meta.url), { type: 'module' });
|
||||||
|
workerRef.current = worker;
|
||||||
|
|
||||||
|
worker.onmessage = (e: MessageEvent) => {
|
||||||
|
if (e.data.type === 'done') {
|
||||||
|
const r = e.data.result;
|
||||||
|
setMiningResult(r);
|
||||||
|
setStatus(`完成,共 ${r.length} 个组件`);
|
||||||
|
setMining(false);
|
||||||
|
worker.terminate();
|
||||||
|
workerRef.current = null;
|
||||||
|
} else if (e.data.type === 'error') {
|
||||||
|
setStatus('计算失败: ' + e.data.error);
|
||||||
|
setMining(false);
|
||||||
|
worker.terminate();
|
||||||
|
workerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.postMessage({ products, options: { minProductCount, minNodeCount: minNodes } });
|
||||||
|
}, [products, minProductCount, minNodes, setMiningResult]);
|
||||||
|
|
||||||
|
// 组件卸载时清理 worker
|
||||||
|
useState(() => () => { if (workerRef.current) workerRef.current.terminate(); });
|
||||||
|
|
||||||
|
const totalNodes = results.reduce((s: number, r: any) => s + r.nodeCount, 0);
|
||||||
|
const checkedList = results.filter((r: any) => checked[r.fingerprint]);
|
||||||
|
|
||||||
|
function openContext(productId: any, path: any, comp: any) {
|
||||||
|
const product = products.find((p: any) => p.id === productId);
|
||||||
|
if (!product) return;
|
||||||
|
let currentNode = product.bom;
|
||||||
|
const breadcrumbs = [{ code: product.bom.part.code }];
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
currentNode = currentNode.children[path[i]];
|
||||||
|
breadcrumbs.push({ code: currentNode.part.code });
|
||||||
|
}
|
||||||
|
const componentNode = currentNode.children[path[path.length - 1]];
|
||||||
|
|
||||||
|
openSidePanel(
|
||||||
|
comp.template.part.name + ' @ ' + productId,
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 14, fontSize: 12, display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center' }}>
|
||||||
|
<strong>{product.name}</strong>
|
||||||
|
<span className="text-secondary" style={{ marginLeft: 6 }}>中位置:</span>
|
||||||
|
{breadcrumbs.map((b: any, i: number) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && <span style={{ color: 'var(--text-3)', margin: '0 2px' }}>→</span>}
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{b.code}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span style={{ color: 'var(--text-3)', margin: '0 2px' }}>→</span>
|
||||||
|
<span style={{ color: 'var(--green)', fontWeight: 600, fontFamily: 'var(--font-mono)', fontSize: 11 }}>{componentNode.part.code}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ border: '1px solid var(--line-1)', borderRadius: 12, padding: 14 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 10 }}>产品 BOM 树(绿色=当前组件)</div>
|
||||||
|
<BomTree key={product.id + comp.fingerprint} root={product.bom} highlightFingerprint={comp.fingerprint} showSpec={false} focus height="70vh" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title"><span className="title-icon"><IconNetwork /></span>挖掘参数</div>
|
||||||
|
<div className="control-row">
|
||||||
|
<span className="control-label">最少覆盖产品:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={minCoveragePct}
|
||||||
|
min="0.1"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
style={{ width: 72 }}
|
||||||
|
disabled={mining}
|
||||||
|
onChange={e => setThresholds({ ...thresholds, minCoveragePct: +e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="control-label" style={{ marginLeft: 0 }}>%</span>
|
||||||
|
<span className="text-muted text-xs" style={{ whiteSpace: 'nowrap' }}>({minProductCount} 个产品)</span>
|
||||||
|
<span className="control-label" style={{ marginLeft: 20 }}>最小节点数:</span>
|
||||||
|
<input type="number" value={minNodes} min="1" max="20" style={{ width: 72 }} disabled={mining} onChange={e => setThresholds({ ...thresholds, minNodes: +e.target.value })} />
|
||||||
|
<button className="btn btn-primary" onClick={runMining} disabled={mining}>
|
||||||
|
{mining ? <><span className="loading-spinner" style={{ width: 14, height: 14, borderWidth: 2, marginRight: 6, display: 'inline-block', verticalAlign: 'middle' }} />计算中</> : '开始挖掘'}
|
||||||
|
</button>
|
||||||
|
<span className="text-secondary text-sm">{status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="stats-bar">
|
||||||
|
<div className="stat-item"><span className="stat-value">{results.length}</span><span className="stat-label">公共组件数</span></div>
|
||||||
|
<div className="stat-item"><span className="stat-value">{totalNodes}</span><span className="stat-label">总节点数</span></div>
|
||||||
|
<div className="stat-item"><span className="stat-value">{(results.reduce((s: number, r: any) => s + r.productCount, 0) / results.length).toFixed(0)}</span><span className="stat-label">平均覆盖产品数</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<div className="card-title" style={{ margin: 0 }}>挖掘结果</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-success"
|
||||||
|
disabled={checkedList.length === 0 || mining}
|
||||||
|
onClick={() => { adoptComponents(checkedList); toast('已批量采纳 ' + checkedList.length + ' 个标准组件'); setChecked({}); setMiningResult([...results]); }}
|
||||||
|
>批量采纳选中项 ({checkedList.length})</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">🔍</div>
|
||||||
|
<div className="empty-state-text">{mining ? '后台计算中,请稍候...' : '调整参数后点击"开始挖掘"'}</div>
|
||||||
|
{!mining && <div className="empty-state-hint">系统将从 {products.length} 个产品中识别可复用的公共组件</div>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
results.map((comp: any, idx: number) => {
|
||||||
|
const isAdopted = standardFps.has(comp.fingerprint);
|
||||||
|
const isOpen = !!expanded[idx];
|
||||||
|
const scarcity = getScarcityLevel(comp.productCount, products.length);
|
||||||
|
const weighted = getWeightedValue(comp.nodeCount, comp.productCount);
|
||||||
|
return (
|
||||||
|
<div className="mining-result-item" key={comp.fingerprint}>
|
||||||
|
<div className="mining-result-header" onClick={() => setExpanded({ ...expanded, [idx]: !isOpen })}>
|
||||||
|
<label className="checkbox-label" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={isAdopted}
|
||||||
|
checked={isAdopted || !!checked[comp.fingerprint]}
|
||||||
|
onChange={e => setChecked({ ...checked, [comp.fingerprint]: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span style={{ fontWeight: 600, flex: 1 }}>{comp.template.part.name}</span>
|
||||||
|
<ScarcityBadge productCount={comp.productCount} totalProducts={products.length} />
|
||||||
|
<span className="mining-stat">节点 <strong>{comp.nodeCount}</strong></span>
|
||||||
|
<span className="mining-stat">产品 <strong>{comp.productCount}</strong></span>
|
||||||
|
<span className="mining-stat">价值 <strong>{comp.productCount * comp.nodeCount}</strong></span>
|
||||||
|
{isAdopted && <span className="badge badge-reuse">已入库</span>}
|
||||||
|
<span className="tree-toggle" style={{ width: 'auto', fontSize: 12 }}>{isOpen ? '收起 \u25B2' : '展开 \u25BC'}</span>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="mining-result-body expanded">
|
||||||
|
<div style={{ display: 'flex', gap: 20 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span className="mining-stat">节点 <strong>{comp.nodeCount}</strong></span>
|
||||||
|
<span className="mining-stat">产品 <strong>{comp.productCount}</strong></span>
|
||||||
|
<span className="mining-stat">覆盖率 <strong>{(comp.productCount / products.length * 100).toFixed(1)}%</strong></span>
|
||||||
|
<span className="mining-stat">稀缺度 <strong style={{ color: scarcity.color }}>{scarcity.level}</strong></span>
|
||||||
|
<span className="mining-stat">加权价值 <strong>{weighted}</strong></span>
|
||||||
|
</div>
|
||||||
|
<strong>组件结构({comp.nodeCount} 节点):</strong>
|
||||||
|
<div className="card" style={{ marginTop: 8, padding: 12 }}><SubTree node={comp.template} /></div>
|
||||||
|
{!isAdopted && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<button className="btn btn-success btn-sm" onClick={() => { adoptComponent(comp); toast('已采纳「' + comp.template.part.name + '」为标准组件'); setMiningResult([...results]); }}>采纳为标准组件</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1.2, minWidth: 0 }}>
|
||||||
|
<strong>引用产品({comp.productCount} 个)— 点击查看上下文:</strong>
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{comp.products.map((p: any) => (
|
||||||
|
<button key={p.id} className="btn btn-sm" style={{ fontSize: 11 }} onClick={() => openContext(p.id, p.path, comp)}>{p.id}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { computeAllFingerprints } from '../core/fingerprint.js';
|
||||||
|
import { getStandardComponents } from '../core/store.js';
|
||||||
|
import BomTree from './BomTree';
|
||||||
|
import { IconTarget, IconSparkle } from './Icons';
|
||||||
|
|
||||||
|
export default function ReuseSearch({ newRequirements }: any) {
|
||||||
|
const [selectedId, setSelectedId] = useState('');
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
const req = newRequirements.find((r: any) => r.id === selectedId);
|
||||||
|
if (!req) return;
|
||||||
|
const standard = getStandardComponents();
|
||||||
|
if (standard.length === 0) { setResult({ empty: true }); return; }
|
||||||
|
|
||||||
|
const standardFpMap = new Map(standard.map((c: any) => [c.fingerprint, c]));
|
||||||
|
const fpMap = computeAllFingerprints(req.bom);
|
||||||
|
|
||||||
|
const allPaths: any[] = [];
|
||||||
|
(function collect(node: any, path: any[]) {
|
||||||
|
allPaths.push(path.join(','));
|
||||||
|
if (node.children) node.children.forEach((c: any, i: number) => collect(c, [...path, i]));
|
||||||
|
})(req.bom, []);
|
||||||
|
|
||||||
|
const hits: any[] = [];
|
||||||
|
for (const [fp] of fpMap) {
|
||||||
|
const sc = standardFpMap.get(fp);
|
||||||
|
if (sc) hits.push({ comp: sc, fingerprint: fp });
|
||||||
|
}
|
||||||
|
|
||||||
|
const covered = new Set<any>();
|
||||||
|
for (const hit of hits) {
|
||||||
|
for (const entry of fpMap.get(hit.fingerprint)) {
|
||||||
|
(function mark(node: any, path: any[]) {
|
||||||
|
covered.add(path.join(','));
|
||||||
|
if (node.children) node.children.forEach((c: any, i: number) => mark(c, [...path, i]));
|
||||||
|
})(entry.node, entry.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未覆盖叶子零件
|
||||||
|
const uncovered: any[] = [];
|
||||||
|
(function walk(node: any, path: any[]) {
|
||||||
|
const isCovered = covered.has(path.join(','));
|
||||||
|
if (!isCovered && (!node.children || node.children.length === 0)) {
|
||||||
|
uncovered.push(node.part);
|
||||||
|
}
|
||||||
|
if (node.children) node.children.forEach((c: any, i: number) => walk(c, [...path, i]));
|
||||||
|
})(req.bom, []);
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
req,
|
||||||
|
hits,
|
||||||
|
highlightSet: new Set(hits.map((h: any) => h.fingerprint)),
|
||||||
|
totalNodes: allPaths.length,
|
||||||
|
covered: covered.size,
|
||||||
|
coverage: allPaths.length > 0 ? ((covered.size / allPaths.length) * 100).toFixed(1) : 0,
|
||||||
|
uncovered,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title"><span className="title-icon"><IconTarget /></span>选择新客户需求</div>
|
||||||
|
<div className="control-row">
|
||||||
|
<select value={selectedId} style={{ width: 400 }} onChange={e => setSelectedId(e.target.value)}>
|
||||||
|
<option value="">-- 请选择新需求样例 --</option>
|
||||||
|
{newRequirements.map((r: any) => <option key={r.id} value={r.id}>{r.id} - {r.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-primary" disabled={!selectedId} onClick={search}><IconSparkle /> 检索复用</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && result.empty && (
|
||||||
|
<div className="empty-state"><div className="empty-state-text">标准组件库为空,请先采纳标准组件</div></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !result.empty && (
|
||||||
|
<div className="reuse-layout">
|
||||||
|
{/* 左:摘要 */}
|
||||||
|
<div className="reuse-left">
|
||||||
|
<div className="stats-bar" style={{ gridTemplateColumns: 'repeat(3, 1fr)', marginBottom: 14 }}>
|
||||||
|
<div className="stat-item"><span className="stat-value">{result.hits.length}</span><span className="stat-label">命中标组件</span></div>
|
||||||
|
<div className="stat-item"><span className="stat-value">{result.coverage}%</span><span className="stat-label">复用覆盖率</span></div>
|
||||||
|
<div className="stat-item"><span className="stat-value">{result.covered}/{result.totalNodes}</span><span className="stat-label">覆盖/总节点</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 14 }}>
|
||||||
|
<div className="card-title" style={{ marginBottom: 8 }}>覆盖率</div>
|
||||||
|
<div className="coverage-bar" style={{ height: 26, margin: 0 }}>
|
||||||
|
<div className="coverage-fill" style={{ width: `${result.coverage}%` }}>{result.coverage}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 14 }}>
|
||||||
|
<div className="card-title" style={{ marginBottom: 8 }}>命中的标准组件({result.hits.length})</div>
|
||||||
|
{result.hits.map((h: any) => (
|
||||||
|
<div key={h.fingerprint} className="hit-item">
|
||||||
|
<span className="badge badge-reuse">命中</span>
|
||||||
|
<strong>{h.comp.template.part.name}</strong>
|
||||||
|
<span className="text-muted text-sm" style={{ marginLeft: 'auto' }}>{h.comp.nodeCount} 节点</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title" style={{ marginBottom: 8 }}>需新设计零件({result.uncovered.length})</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{result.uncovered.map((p: any, i: number) => (
|
||||||
|
<span key={i} className="uncovered-tag" title={p.spec}>{p.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右:BOM 树(绿色=命中) */}
|
||||||
|
<div className="reuse-right">
|
||||||
|
<div className="card" style={{ marginBottom: 0, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div className="card-title" style={{ marginBottom: 10 }}>
|
||||||
|
新需求 BOM(绿色=命中)
|
||||||
|
<span className="text-muted text-sm" style={{ marginLeft: 'auto' }}>{result.req.name}</span>
|
||||||
|
</div>
|
||||||
|
<BomTree root={result.req.bom} highlightSet={result.highlightSet} focus height="calc(100vh - 250px)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { IconX } from './Icons';
|
||||||
|
|
||||||
|
// 全局左右分栏侧面板:打开时内容区自动收缩,实现左右联动
|
||||||
|
let api: { open: (t: string, b: any) => void; close: () => void } | null = null;
|
||||||
|
|
||||||
|
export function openSidePanel(title: string, body: any) {
|
||||||
|
if (api) api.open(title, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSidePanel() {
|
||||||
|
if (api) api.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidePanelHost({ width = 480 }: any) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [body, setBody] = useState<any>(null);
|
||||||
|
|
||||||
|
const openFn = useCallback((t: string, b: any) => { setTitle(t); setBody(b); setOpen(true); }, []);
|
||||||
|
const closeFn = useCallback(() => setOpen(false), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api = { open: openFn, close: closeFn };
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeFn(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => { api = null; window.removeEventListener('keydown', onKey); };
|
||||||
|
}, [openFn, closeFn]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`side-panel ${open ? 'side-panel-open' : ''}`}
|
||||||
|
style={{ width: open ? width : 0 }}
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
<div className="side-panel-inner" style={{ width }}>
|
||||||
|
<div className="side-panel-header">
|
||||||
|
<span className="side-panel-title">{title}</span>
|
||||||
|
<button type="button" className="drawer-close" onClick={closeFn} aria-label="关闭" title="关闭">
|
||||||
|
<IconX />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="side-panel-content">{body}</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { findSimilarProductsBatch, computeSimilarity } from '../core/similarity.js';
|
||||||
|
import { computeProductNodeCounts } from '../core/mining.js';
|
||||||
|
import { openSidePanel } from './SidePanel';
|
||||||
|
import { IconCompare } from './Icons';
|
||||||
|
|
||||||
|
function SubTree({ node, depth = 0 }: any) {
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.8, paddingLeft: depth * 12 }}>
|
||||||
|
<span style={{ color: 'var(--accent-primary)' }}>{node.part.code}</span> {node.part.name} <span className="text-muted">x{node.qty}</span>
|
||||||
|
{node.children && node.children.map((c: any, i: number) => <SubTree key={i} node={c} depth={depth + 1} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集 BOM 树中所有零件编码
|
||||||
|
function getAllCodes(node: any, set: Set<any>) {
|
||||||
|
set.add(node.part.code);
|
||||||
|
if (node.children) node.children.forEach((c: any) => getAllCodes(c, set));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SimilarityView({ products, miningResult }: any) {
|
||||||
|
const [selectedId, setSelectedId] = useState('');
|
||||||
|
const [similar, setSimilar] = useState<any>(null);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [searchProgress, setSearchProgress] = useState<any>({ done: 0, total: 0 });
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const nodeCounts = useMemo(() => computeProductNodeCounts(products), [products]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => abortRef.current?.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const find = useCallback(async () => {
|
||||||
|
if (!selectedId || miningResult.length === 0) return;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
setSimilar(null);
|
||||||
|
setSearchProgress({ done: 0, total: 0 });
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
await new Promise<void>(r => requestAnimationFrame(() => r()));
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
const accumulated: any[] = [];
|
||||||
|
|
||||||
|
await findSimilarProductsBatch(selectedId, miningResult, nodeCounts, {
|
||||||
|
batchSize: 50,
|
||||||
|
signal: controller.signal,
|
||||||
|
onBatch: (batch: any, info: any) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
accumulated.push(...batch);
|
||||||
|
const sorted = [...accumulated].sort((a: any, b: any) =>
|
||||||
|
b.normalizedPct - a.normalizedPct ||
|
||||||
|
b.weightedScore - a.weightedScore ||
|
||||||
|
b.score - a.score
|
||||||
|
);
|
||||||
|
setSimilar(sorted);
|
||||||
|
setSearchProgress(info);
|
||||||
|
},
|
||||||
|
onDone: (allResults: any) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setSimilar(allResults);
|
||||||
|
setSearching(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setSearching(false);
|
||||||
|
}, [selectedId, miningResult, nodeCounts]);
|
||||||
|
|
||||||
|
function openDetail(target: any, item: any) {
|
||||||
|
const targetProduct = products.find((p: any) => p.id === target);
|
||||||
|
const other = products.find((p: any) => p.id === item.productId);
|
||||||
|
const sim: any = computeSimilarity(target, item.productId, miningResult, nodeCounts);
|
||||||
|
|
||||||
|
// 计算各自独有零件
|
||||||
|
const codesA = new Set<any>();
|
||||||
|
const codesB = new Set<any>();
|
||||||
|
if (targetProduct) getAllCodes(targetProduct.bom, codesA);
|
||||||
|
if (other) getAllCodes(other.bom, codesB);
|
||||||
|
const onlyA = [...codesA].filter((c: any) => !codesB.has(c));
|
||||||
|
const onlyB = [...codesB].filter((c: any) => !codesA.has(c));
|
||||||
|
|
||||||
|
openSidePanel(
|
||||||
|
target + ' vs ' + item.productId,
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div className="mining-stat">BOM 重合度 <strong>{sim.normalizedPct}%</strong></div>
|
||||||
|
<div className="mining-stat">共享组件 <strong>{sim.sharedCount}</strong></div>
|
||||||
|
<div className="mining-stat">共享节点 <strong>{sim.score}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div className="mining-stat" style={{ background: 'var(--surface-2)' }}>
|
||||||
|
{target} 独有零件 <strong style={{ color: 'var(--orange)' }}>{onlyA.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="mining-stat" style={{ background: 'var(--surface-2)' }}>
|
||||||
|
{item.productId} 独有零件 <strong style={{ color: 'var(--orange)' }}>{onlyB.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="mining-stat" style={{ background: 'var(--surface-2)' }}>
|
||||||
|
{target} 总节点 <strong>{nodeCounts.get(target)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="mining-stat" style={{ background: 'var(--surface-2)' }}>
|
||||||
|
{item.productId} 总节点 <strong>{nodeCounts.get(item.productId)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary" style={{ marginBottom: 16, fontSize: 13, lineHeight: 1.6 }}>
|
||||||
|
BOM 重合度 = 共享公共组件节点数 / 两者中较大产品的总节点数。
|
||||||
|
{sim.normalizedPct === 100 && ' 重合度 100% 表示较大产品的所有节点均被公共组件覆盖,但双方仍各有独有定制件,不代表两个 BOM 完全相同。'}
|
||||||
|
</p>
|
||||||
|
{sim.sharedComponents.map((comp: any, i: number) => (
|
||||||
|
<div key={i} style={{ border: '1px solid var(--line-1)', borderRadius: 12, marginBottom: 12, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '10px 14px', background: 'var(--surface-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13 }}>{comp.template.part.name}</span>
|
||||||
|
<span className="mining-stat">节点 <strong>{comp.nodeCount}</strong></span>
|
||||||
|
<span className="mining-stat">覆盖率 <strong>{comp.productCount}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '12px 14px' }}>
|
||||||
|
<SubTree node={comp.template} />
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text-3)' }}>同时出现在 {target} 与 {other?.id} 中</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* 独有零件列表 */}
|
||||||
|
{(onlyA.length > 0 || onlyB.length > 0) && (
|
||||||
|
<div style={{ marginTop: 16, border: '1px solid var(--line-1)', borderRadius: 12, padding: 14 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 10 }}>双方差异零件</div>
|
||||||
|
{onlyA.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--orange)', fontWeight: 600 }}>{target} 独有({onlyA.length}):</span>
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{onlyA.map((c: any, i: number) => (
|
||||||
|
<span key={i} style={{ fontSize: 11, padding: '3px 8px', background: 'var(--surface-2)', border: '1px solid var(--line-1)', borderRadius: 6, fontFamily: 'var(--font-mono)' }}>{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onlyB.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--orange)', fontWeight: 600 }}>{item.productId} 独有({onlyB.length}):</span>
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{onlyB.map((c: any, i: number) => (
|
||||||
|
<span key={i} style={{ fontSize: 11, padding: '3px 8px', background: 'var(--surface-2)', border: '1px solid var(--line-1)', borderRadius: 6, fontFamily: 'var(--font-mono)' }}>{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title"><span className="title-icon"><IconCompare /></span>选择目标产品</div>
|
||||||
|
<div className="control-row">
|
||||||
|
<select value={selectedId} style={{ width: 400 }} onChange={e => setSelectedId(e.target.value)} disabled={searching}>
|
||||||
|
<option value="">-- 请选择产品 --</option>
|
||||||
|
{products.map((p: any) => <option key={p.id} value={p.id}>{p.id} - {p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-primary" disabled={!selectedId || searching || miningResult.length === 0} onClick={find}>
|
||||||
|
{searching ? '分析中...' : '查找相似产品'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{miningResult.length === 0 && <div className="text-secondary text-sm" style={{ marginTop: 8 }}>提示:请先在"公共组件挖掘"页面执行挖掘</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searching && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="sim-searching">
|
||||||
|
<span className="loading-spinner" />
|
||||||
|
<span>正在分析相似产品... {searchProgress.total > 0 ? `${searchProgress.done}/${searchProgress.total}` : ''}</span>
|
||||||
|
{similar && similar.length > 0 && <span className="sim-searching-count">已找到 {similar.length} 个</span>}
|
||||||
|
</div>
|
||||||
|
{searchProgress.total > 0 && (
|
||||||
|
<div className="load-progress-bar-track" style={{ marginTop: 10 }}>
|
||||||
|
<div
|
||||||
|
className="load-progress-bar-fill"
|
||||||
|
style={{ width: `${(searchProgress.done / searchProgress.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{similar && similar.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">
|
||||||
|
相似产品(共 {similar.length} 个{searching ? ',持续更新中...' : ''})— 按 BOM 重合度排序
|
||||||
|
</div>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>排名</th>
|
||||||
|
<th>产品编码</th>
|
||||||
|
<th>产品名称</th>
|
||||||
|
<th>BOM 重合度</th>
|
||||||
|
<th>共享组件</th>
|
||||||
|
<th>共享节点</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{similar.map((s: any, i: number) => {
|
||||||
|
const p = products.find((x: any) => x.id === s.productId);
|
||||||
|
const pct = s.normalizedPct;
|
||||||
|
return (
|
||||||
|
<tr key={s.productId} className="sim-row-appear">
|
||||||
|
<td><strong>#{i + 1}</strong></td>
|
||||||
|
<td className="font-mono">{s.productId}</td>
|
||||||
|
<td>{p?.name || s.productId}</td>
|
||||||
|
<td>
|
||||||
|
<div className="coverage-bar" style={{ height: 16, width: 120 }}>
|
||||||
|
<div className="coverage-fill" style={{ width: `${pct}%` }}>{pct}%</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{s.sharedCount} 个</td>
|
||||||
|
<td>{s.score}</td>
|
||||||
|
<td><button className="btn btn-sm" onClick={() => openDetail(selectedId, s)}>详情</button></td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{similar && similar.length === 0 && !searching && (
|
||||||
|
<div className="empty-state"><div className="empty-state-text">未找到相似产品</div></div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Skeleton.tsx - 加载骨架屏
|
||||||
|
export default function Skeleton({ rows = 5, height = 40 }: any) {
|
||||||
|
return (
|
||||||
|
<div className="skeleton-wrap">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div key={i} className="skeleton" style={{ height, animationDelay: `${i * 0.08}s` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getStandardComponents, removeComponent, clearStore, subscribe } from '../core/store.js';
|
||||||
|
import { openSidePanel } from './SidePanel';
|
||||||
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
import BomTree from './BomTree';
|
||||||
|
import { toast } from './Toast';
|
||||||
|
import { IconLayers, IconEye, IconTrash } from './Icons';
|
||||||
|
|
||||||
|
export default function StandardLibrary() {
|
||||||
|
const [components, setComponents] = useState<any[]>(getStandardComponents());
|
||||||
|
const [confirm, setConfirm] = useState<any>({ open: false, type: null, fingerprint: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = subscribe(() => setComponents(getStandardComponents()));
|
||||||
|
return () => { unsubscribe(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalNodes = components.reduce((s: number, c: any) => s + c.nodeCount, 0);
|
||||||
|
|
||||||
|
function openStructure(comp: any) {
|
||||||
|
openSidePanel(
|
||||||
|
comp.template.part.name,
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
|
||||||
|
<span className="mining-stat">节点 <strong>{comp.nodeCount}</strong></span>
|
||||||
|
<span className="mining-stat">来源产品 <strong>{comp.productCount}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div style={{ border: '1px solid var(--line-1)', borderRadius: 12, padding: 14, marginBottom: 16 }}>
|
||||||
|
<BomTree root={comp.template} height="60vh" />
|
||||||
|
</div>
|
||||||
|
<div style={{ border: '1px solid var(--line-1)', borderRadius: 12, padding: 14 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 8 }}>来源产品({comp.sourceProducts.length} 个)</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{comp.sourceProducts.map((p: any) => (
|
||||||
|
<span key={p.id} style={{ fontSize: 11, padding: '3px 8px', background: 'var(--indigo-soft)', border: '1px solid var(--line-1)', borderRadius: 6, fontFamily: 'var(--font-mono)' }}>{p.id}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center mb-16">
|
||||||
|
<div className="card-title" style={{ margin: 0 }}><span className="title-icon"><IconLayers /></span>标准组件库</div>
|
||||||
|
{components.length > 0 && (
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ open: true, type: 'clear' })}>清空组件库</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{components.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">📭</div>
|
||||||
|
<div className="empty-state-text">标准组件库为空</div>
|
||||||
|
<div className="empty-state-hint">请前往"公共组件挖掘"页面采纳标准组件</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="stats-bar">
|
||||||
|
<div className="stat-item"><span className="stat-value">{components.length}</span><span className="stat-label">标准组件数</span></div>
|
||||||
|
<div className="stat-item"><span className="stat-value">{totalNodes}</span><span className="stat-label">总节点数</span></div>
|
||||||
|
</div>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead><tr><th>序号</th><th>组件名称</th><th>节点数</th><th>来源产品数</th><th>入库时间</th><th>操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{components.map((comp: any, i: number) => (
|
||||||
|
<tr key={comp.fingerprint}>
|
||||||
|
<td>{i + 1}</td>
|
||||||
|
<td><strong>{comp.template.part.name}</strong> <span className="text-secondary text-sm">{comp.template.part.code}</span></td>
|
||||||
|
<td>{comp.nodeCount}</td>
|
||||||
|
<td>{comp.productCount}</td>
|
||||||
|
<td className="text-secondary">{new Date(comp.adoptedAt).toLocaleString()}</td>
|
||||||
|
<td style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button className="btn btn-sm" onClick={() => openStructure(comp)}><IconEye /> 查看结构</button>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm({ open: true, type: 'remove', fingerprint: comp.fingerprint })}><IconTrash /> 移除</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirm.open}
|
||||||
|
title={confirm.type === 'clear' ? '清空标准组件库' : '移除标准组件'}
|
||||||
|
message={confirm.type === 'clear' ? `确定要清空整个标准组件库吗?(共 ${components.length} 个组件)` : '确定要移除此标准组件吗?'}
|
||||||
|
confirmText={confirm.type === 'clear' ? '清空' : '移除'}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (confirm.type === 'clear') { clearStore(); toast('标准组件库已清空', 'info'); }
|
||||||
|
else { removeComponent(confirm.fingerprint); toast('已移除标准组件', 'info'); }
|
||||||
|
setConfirm({ ...confirm, open: false });
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirm({ ...confirm, open: false })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
// 全局 Toast 系统
|
||||||
|
let pushToast: ((message: string, type?: string) => void) | null = null;
|
||||||
|
|
||||||
|
export function toast(message: string, type = 'success') {
|
||||||
|
if (pushToast) pushToast(message, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToastContainer() {
|
||||||
|
const [items, setItems] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const add = useCallback((message: string, type?: string) => {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
setItems(prev => [...prev, { id, message, type }]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setItems(prev => prev.filter((t: any) => t.id !== id));
|
||||||
|
}, 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { pushToast = add; return () => { pushToast = null; }; }, [add]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toast-container">
|
||||||
|
{items.map(t => (
|
||||||
|
<div key={t.id} className={`toast toast-${t.type}`}>
|
||||||
|
<span className="toast-dot"></span>
|
||||||
|
{t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// fingerprint.js - Optimized: 64-bit dual-hash + O(N) bottom-up + WeakMap memoization
|
||||||
|
|
||||||
|
// --- 64-bit dual-hash: two independent djb2 variants combined ---
|
||||||
|
// h1: classic djb2 (×33), h2: sdbm variant (×31)
|
||||||
|
// 64-bit collision space = 1.8×10¹⁹, 万级数据无碰撞风险
|
||||||
|
function djb2Hash64(str) {
|
||||||
|
let h1 = 5381;
|
||||||
|
let h2 = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const c = str.charCodeAt(i);
|
||||||
|
h1 = ((h1 << 5) + h1 + c) | 0; // h1 = h1 × 33 + c
|
||||||
|
h2 = ((h2 << 5) - h2 + c) | 0; // h2 = h2 × 31 + c (sdbm)
|
||||||
|
}
|
||||||
|
return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WeakMap memoization: same node object → cached fingerprint ---
|
||||||
|
let fpMemo = new WeakMap();
|
||||||
|
|
||||||
|
export function clearFingerprintCache() {
|
||||||
|
fpMemo = new WeakMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-node fingerprint (backward compatible, now memoized + 64-bit)
|
||||||
|
export function computeFingerprint(node) {
|
||||||
|
if (fpMemo.has(node)) return fpMemo.get(node);
|
||||||
|
const code = node.part.code;
|
||||||
|
const qty = node.qty;
|
||||||
|
const spec = node.part.spec || '';
|
||||||
|
let fp;
|
||||||
|
if (!node.children || node.children.length === 0) {
|
||||||
|
fp = djb2Hash64(code + '|' + qty + '|' + spec);
|
||||||
|
} else {
|
||||||
|
// 子节点指纹排序后拼接 → 排列顺序不影响指纹
|
||||||
|
const childFingerprints = node.children.map(c => computeFingerprint(c)).sort();
|
||||||
|
fp = djb2Hash64(code + '|' + qty + '|' + spec + '|' + childFingerprints.join(','));
|
||||||
|
}
|
||||||
|
fpMemo.set(node, fp);
|
||||||
|
return fp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// O(N) optimized: bottom-up traversal, each node computed exactly once
|
||||||
|
// 返回 Map<指纹, [{node, path, fingerprint}]> — 与旧版接口完全兼容
|
||||||
|
export function computeAllFingerprints(rootNode) {
|
||||||
|
const resultMap = new Map();
|
||||||
|
function traverse(node, path) {
|
||||||
|
const fp = computeFingerprint(node); // 首次调用触发递归计算 + 缓存,后续 O(1)
|
||||||
|
const entry = { node, path, fingerprint: fp };
|
||||||
|
if (!resultMap.has(fp)) resultMap.set(fp, []);
|
||||||
|
resultMap.get(fp).push(entry);
|
||||||
|
if (node.children) {
|
||||||
|
for (let i = 0; i < node.children.length; i++) {
|
||||||
|
traverse(node.children[i], path.concat([i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traverse(rootNode, []);
|
||||||
|
return resultMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countNodes(node) {
|
||||||
|
if (!node.children || node.children.length === 0) return 1;
|
||||||
|
return 1 + node.children.reduce((s, c) => s + countNodes(c), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// mining-worker.js — Web Worker:后台执行挖掘,不阻塞 UI
|
||||||
|
import { minePublicComponents } from './mining.js';
|
||||||
|
|
||||||
|
self.onmessage = (e) => {
|
||||||
|
const { products, options, jobId } = e.data;
|
||||||
|
try {
|
||||||
|
const result = minePublicComponents(products, options);
|
||||||
|
self.postMessage({ type: 'done', result, jobId });
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({ type: 'error', error: err.message, jobId });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// mining.js - Optimized: O(1) product lookup in dedup + staged mining
|
||||||
|
|
||||||
|
import { computeAllFingerprints, countNodes, clearFingerprintCache } from './fingerprint.js';
|
||||||
|
|
||||||
|
// 预计算所有产品的节点数,供 similarity 归一化使用
|
||||||
|
export function computeProductNodeCounts(products) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const p of products) {
|
||||||
|
map.set(p.id, countNodes(p.bom));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 稀缺度等级
|
||||||
|
export function getScarcityLevel(productCount, totalProducts) {
|
||||||
|
const ratio = productCount / totalProducts;
|
||||||
|
if (ratio <= 0.05) return { level: '稀有', color: 'var(--red)', weight: '高' };
|
||||||
|
if (ratio <= 0.2) return { level: '较稀有', color: 'var(--orange)', weight: '中' };
|
||||||
|
if (ratio <= 0.5) return { level: '常见', color: 'var(--blue)', weight: '低' };
|
||||||
|
return { level: '通用', color: 'var(--text-3)', weight: '极低' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加权价值
|
||||||
|
export function getWeightedValue(nodeCount, productCount) {
|
||||||
|
const weight = 1 / Math.log2(productCount + 1);
|
||||||
|
return Math.round(nodeCount * weight * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 分阶段函数 ===
|
||||||
|
|
||||||
|
// 阶段 1:指纹计算
|
||||||
|
export function miningStage1_Fingerprint(products, onProgress) {
|
||||||
|
const t0 = performance.now();
|
||||||
|
clearFingerprintCache();
|
||||||
|
const fpGroups = new Map();
|
||||||
|
for (let i = 0; i < products.length; i++) {
|
||||||
|
const product = products[i];
|
||||||
|
const fpMap = computeAllFingerprints(product.bom);
|
||||||
|
for (const [fp, entries] of fpMap) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.path.length === 0) continue;
|
||||||
|
if (!fpGroups.has(fp)) {
|
||||||
|
fpGroups.set(fp, {
|
||||||
|
fingerprint: fp,
|
||||||
|
template: entry.node,
|
||||||
|
nodeCount: countNodes(entry.node),
|
||||||
|
products: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fpGroups.get(fp).products.push({ id: product.id, name: product.name, path: entry.path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress(i + 1, products.length);
|
||||||
|
}
|
||||||
|
return { fpGroups, time: performance.now() - t0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段 2:阈值过滤
|
||||||
|
export function miningStage2_Filter(fpGroups, minProductCount, minNodeCount) {
|
||||||
|
const t0 = performance.now();
|
||||||
|
const candidates = [];
|
||||||
|
for (const group of fpGroups.values()) {
|
||||||
|
const uniqueProducts = new Map();
|
||||||
|
for (const p of group.products) {
|
||||||
|
if (!uniqueProducts.has(p.id)) uniqueProducts.set(p.id, p);
|
||||||
|
}
|
||||||
|
group.products = Array.from(uniqueProducts.values());
|
||||||
|
group.productCount = group.products.length;
|
||||||
|
if (group.productCount >= minProductCount && group.nodeCount >= minNodeCount) {
|
||||||
|
candidates.push(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { candidates, time: performance.now() - t0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段 3:子路径去重 — 核心优化
|
||||||
|
// 旧版瓶颈:每对候选比较时 products.some() 线性查找 → O(C²×P²)
|
||||||
|
// 新版优化:为每个候选预建 productId→path 索引 → O(C²×P)
|
||||||
|
export function miningStage3_Dedup(candidates) {
|
||||||
|
const t0 = performance.now();
|
||||||
|
candidates.sort((a, b) => b.nodeCount - a.nodeCount);
|
||||||
|
|
||||||
|
// 预建索引:每个候选的 productId → path 映射
|
||||||
|
for (const c of candidates) {
|
||||||
|
c._productIndex = new Map();
|
||||||
|
for (const p of c.products) {
|
||||||
|
c._productIndex.set(p.id, p.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
// acceptedPaths: Map<productId, Map<fingerprint, path>>
|
||||||
|
// 用于快速判断某产品在某已入选候选中的路径
|
||||||
|
const acceptedProductPaths = new Map();
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
let isContained = false;
|
||||||
|
|
||||||
|
// 遍历候选的所有产品,检查是否每个产品路径都是某个已入选候选路径的子路径
|
||||||
|
// 只需要检查产品集是候选子集的已入选项
|
||||||
|
// 快速排除:候选产品数 > 已入选项产品数的直接跳过
|
||||||
|
for (const accepted of result) {
|
||||||
|
// 产品集大小检查:候选产品数必须 <= 已入选项产品数才可能被包含
|
||||||
|
if (candidate.productCount > accepted.productCount) continue;
|
||||||
|
|
||||||
|
// 检查候选的所有产品是否都在 accepted 中,且路径是子路径
|
||||||
|
let allContained = true;
|
||||||
|
for (const cp of candidate.products) {
|
||||||
|
const acceptedPath = accepted._productIndex.get(cp.id);
|
||||||
|
if (!acceptedPath || !isSubPath(acceptedPath, cp.path)) {
|
||||||
|
allContained = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allContained) { isContained = true; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isContained) {
|
||||||
|
result.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理临时索引
|
||||||
|
for (const c of candidates) delete c._productIndex;
|
||||||
|
for (const c of result) delete c._productIndex;
|
||||||
|
|
||||||
|
result.sort((a, b) => (b.productCount * b.nodeCount) - (a.productCount * a.nodeCount));
|
||||||
|
return { result, time: performance.now() - t0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 一体化函数 ===
|
||||||
|
export function minePublicComponents(products, options = {}) {
|
||||||
|
const minProductCount = options.minProductCount ?? 3;
|
||||||
|
const minNodeCount = options.minNodeCount ?? 3;
|
||||||
|
|
||||||
|
const { fpGroups, time: fpTime } = miningStage1_Fingerprint(products);
|
||||||
|
const { candidates, time: filterTime } = miningStage2_Filter(fpGroups, minProductCount, minNodeCount);
|
||||||
|
const { result, time: dedupTime } = miningStage3_Dedup(candidates);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
fingerprintTime: +fpTime.toFixed(1),
|
||||||
|
filterTime: +filterTime.toFixed(1),
|
||||||
|
dedupTime: +dedupTime.toFixed(1),
|
||||||
|
totalTime: +(fpTime + filterTime + dedupTime).toFixed(1),
|
||||||
|
totalFingerprints: fpGroups.size,
|
||||||
|
candidatesBeforeDedup: candidates.length,
|
||||||
|
candidatesAfterDedup: result.length,
|
||||||
|
dedupRemoved: candidates.length - result.length,
|
||||||
|
hashBits: 64,
|
||||||
|
};
|
||||||
|
|
||||||
|
result.stats = stats;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSubPath(pathA, pathB) {
|
||||||
|
if (pathA.length >= pathB.length) return false;
|
||||||
|
for (let i = 0; i < pathA.length; i++) {
|
||||||
|
if (pathA[i] !== pathB[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// similarity.js - Improved: 倒排索引 + 归一化分数 + 稀缺度加权 + 复合排序 + 分批异步
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两个产品的相似度
|
||||||
|
* @param {string} productIdA - 产品A的ID
|
||||||
|
* @param {string} productIdB - 产品B的ID
|
||||||
|
* @param {Array} publicComponents - 公共组件列表(来自 mining.js)
|
||||||
|
* @param {Map} productNodeCounts - 产品节点数映射(来自 computeProductNodeCounts)
|
||||||
|
* @returns {Object} 相似度结果
|
||||||
|
*/
|
||||||
|
export function computeSimilarity(productIdA, productIdB, publicComponents, productNodeCounts) {
|
||||||
|
let sharedNodes = 0;
|
||||||
|
let weightedScore = 0;
|
||||||
|
let sharedCount = 0;
|
||||||
|
const sharedComponents = [];
|
||||||
|
|
||||||
|
for (const comp of publicComponents) {
|
||||||
|
const productIds = comp.products.map(p => p.id);
|
||||||
|
const hasA = productIds.includes(productIdA);
|
||||||
|
const hasB = productIds.includes(productIdB);
|
||||||
|
if (hasA && hasB) {
|
||||||
|
sharedNodes += comp.nodeCount;
|
||||||
|
sharedCount++;
|
||||||
|
const weight = 1 / Math.log2(comp.productCount + 1);
|
||||||
|
weightedScore += comp.nodeCount * weight;
|
||||||
|
sharedComponents.push({
|
||||||
|
fingerprint: comp.fingerprint,
|
||||||
|
nodeCount: comp.nodeCount,
|
||||||
|
template: comp.template,
|
||||||
|
productCount: comp.productCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalA = productNodeCounts?.get(productIdA) ?? 0;
|
||||||
|
const totalB = productNodeCounts?.get(productIdB) ?? 0;
|
||||||
|
// 归一化:共享节点 / min(A, B),上限 100%
|
||||||
|
// 公共组件可能嵌套重叠,累加值会超过单产品节点总数,所以用 min 做分母并 cap
|
||||||
|
const denominator = Math.max(totalA, totalB) || 1;
|
||||||
|
const normalizedScore = Math.min(sharedNodes / denominator, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
score: sharedNodes,
|
||||||
|
normalizedScore,
|
||||||
|
normalizedPct: +(normalizedScore * 100).toFixed(1),
|
||||||
|
weightedScore: +weightedScore.toFixed(2),
|
||||||
|
sharedCount,
|
||||||
|
sharedComponents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预建倒排索引:productId → 该产品参与的所有公共组件数组
|
||||||
|
*/
|
||||||
|
export function buildInvertedIndex(publicComponents) {
|
||||||
|
const index = new Map();
|
||||||
|
for (const comp of publicComponents) {
|
||||||
|
for (const p of comp.products) {
|
||||||
|
if (!index.has(p.id)) index.set(p.id, []);
|
||||||
|
index.get(p.id).push(comp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用倒排索引快速计算两个产品的相似度(交集)
|
||||||
|
*/
|
||||||
|
function computeSimilarityFast(productIdA, productIdB, index, productNodeCounts) {
|
||||||
|
const compsA = index.get(productIdA);
|
||||||
|
const compsB = index.get(productIdB);
|
||||||
|
if (!compsA || !compsB) {
|
||||||
|
return {
|
||||||
|
score: 0, normalizedScore: 0, normalizedPct: 0,
|
||||||
|
weightedScore: 0, sharedCount: 0, sharedComponents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fpsB = new Set(compsB.map(c => c.fingerprint));
|
||||||
|
let sharedNodes = 0;
|
||||||
|
let weightedScore = 0;
|
||||||
|
let sharedCount = 0;
|
||||||
|
const sharedComponents = [];
|
||||||
|
|
||||||
|
for (const comp of compsA) {
|
||||||
|
if (!fpsB.has(comp.fingerprint)) continue;
|
||||||
|
sharedNodes += comp.nodeCount;
|
||||||
|
sharedCount++;
|
||||||
|
const weight = 1 / Math.log2(comp.productCount + 1);
|
||||||
|
weightedScore += comp.nodeCount * weight;
|
||||||
|
sharedComponents.push({
|
||||||
|
fingerprint: comp.fingerprint,
|
||||||
|
nodeCount: comp.nodeCount,
|
||||||
|
template: comp.template,
|
||||||
|
productCount: comp.productCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalA = productNodeCounts?.get(productIdA) ?? 0;
|
||||||
|
const totalB = productNodeCounts?.get(productIdB) ?? 0;
|
||||||
|
const denominator = Math.max(totalA, totalB) || 1;
|
||||||
|
const normalizedScore = Math.min(sharedNodes / denominator, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
score: sharedNodes,
|
||||||
|
normalizedScore,
|
||||||
|
normalizedPct: +(normalizedScore * 100).toFixed(1),
|
||||||
|
weightedScore: +weightedScore.toFixed(2),
|
||||||
|
sharedCount,
|
||||||
|
sharedComponents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步版(向后兼容)
|
||||||
|
*/
|
||||||
|
export function findSimilarProducts(targetProductId, publicComponents, productNodeCounts) {
|
||||||
|
const index = buildInvertedIndex(publicComponents);
|
||||||
|
|
||||||
|
const allProductIds = new Set();
|
||||||
|
for (const comp of publicComponents) {
|
||||||
|
for (const p of comp.products) allProductIds.add(p.id);
|
||||||
|
}
|
||||||
|
allProductIds.delete(targetProductId);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const otherId of allProductIds) {
|
||||||
|
const sim = computeSimilarityFast(targetProductId, otherId, index, productNodeCounts);
|
||||||
|
if (sim.score > 0) {
|
||||||
|
results.push({
|
||||||
|
productId: otherId,
|
||||||
|
score: sim.score,
|
||||||
|
normalizedPct: sim.normalizedPct,
|
||||||
|
weightedScore: sim.weightedScore,
|
||||||
|
sharedCount: sim.sharedCount,
|
||||||
|
sharedComponents: sim.sharedComponents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) =>
|
||||||
|
b.normalizedPct - a.normalizedPct ||
|
||||||
|
b.weightedScore - a.weightedScore ||
|
||||||
|
b.score - a.score
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分批异步计算相似产品,每批算完回调一次,不阻塞 UI
|
||||||
|
*/
|
||||||
|
export async function findSimilarProductsBatch(targetProductId, publicComponents, productNodeCounts, options = {}) {
|
||||||
|
const { batchSize = 50, onBatch, onDone, signal } = options;
|
||||||
|
|
||||||
|
const index = buildInvertedIndex(publicComponents);
|
||||||
|
|
||||||
|
const allProductIds = new Set();
|
||||||
|
for (const comp of publicComponents) {
|
||||||
|
for (const p of comp.products) allProductIds.add(p.id);
|
||||||
|
}
|
||||||
|
allProductIds.delete(targetProductId);
|
||||||
|
|
||||||
|
const ids = Array.from(allProductIds);
|
||||||
|
const total = ids.length;
|
||||||
|
const allResults = [];
|
||||||
|
|
||||||
|
for (let start = 0; start < total; start += batchSize) {
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
|
||||||
|
const end = Math.min(start + batchSize, total);
|
||||||
|
const batch = [];
|
||||||
|
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const otherId = ids[i];
|
||||||
|
const sim = computeSimilarityFast(targetProductId, otherId, index, productNodeCounts);
|
||||||
|
if (sim.score > 0) {
|
||||||
|
batch.push({
|
||||||
|
productId: otherId,
|
||||||
|
score: sim.score,
|
||||||
|
normalizedPct: sim.normalizedPct,
|
||||||
|
weightedScore: sim.weightedScore,
|
||||||
|
sharedCount: sim.sharedCount,
|
||||||
|
sharedComponents: sim.sharedComponents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults.push(...batch);
|
||||||
|
|
||||||
|
if (onBatch) onBatch(batch, { done: end, total });
|
||||||
|
|
||||||
|
if (start + batchSize < total) {
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults.sort((a, b) =>
|
||||||
|
b.normalizedPct - a.normalizedPct ||
|
||||||
|
b.weightedScore - a.weightedScore ||
|
||||||
|
b.score - a.score
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onDone) onDone(allResults);
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// store.js - 标准组件库状态管理(带订阅机制)
|
||||||
|
const STORAGE_KEY = 'bom-standard-components';
|
||||||
|
let components = [];
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
components = raw ? JSON.parse(raw) : [];
|
||||||
|
} catch { components = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(components)); } catch {}
|
||||||
|
listeners.forEach(fn => fn(components));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(fn) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStandardComponents() {
|
||||||
|
return components.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initStore() { load(); }
|
||||||
|
|
||||||
|
export function adoptComponent(component) {
|
||||||
|
if (components.some(c => c.fingerprint === component.fingerprint)) return;
|
||||||
|
components.push({
|
||||||
|
fingerprint: component.fingerprint,
|
||||||
|
template: component.template,
|
||||||
|
nodeCount: component.nodeCount,
|
||||||
|
productCount: component.productCount,
|
||||||
|
sourceProducts: component.products,
|
||||||
|
adoptedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adoptComponents(list) {
|
||||||
|
list.forEach(c => adoptComponent(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeComponent(fingerprint) {
|
||||||
|
components = components.filter(c => c.fingerprint !== fingerprint);
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStore() {
|
||||||
|
components = [];
|
||||||
|
save();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ModuleConfig } from '../registry';
|
||||||
|
|
||||||
|
export const moduleConfig: ModuleConfig = {
|
||||||
|
id: 'bomx-reuse-analyzer',
|
||||||
|
name: '企业级BOM标准化治理',
|
||||||
|
description: 'BOM 可复用组件挖掘分析系统:公共组件挖掘、相似度计算、标准库治理',
|
||||||
|
icon: 'Boxes',
|
||||||
|
path: '/bomx-reuse-analyzer',
|
||||||
|
accentColor: '#6366F1',
|
||||||
|
accentColorLight: '#4F46E5',
|
||||||
|
gradient: 'linear-gradient(135deg, #1e1b4b, #312e81, #4338ca)',
|
||||||
|
gradientLight: 'linear-gradient(135deg, #eef2ff, #e0e7ff, #c7d2fe)',
|
||||||
|
tags: ['BOM', '复用', '挖掘'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { initStore } from '../core/store';
|
||||||
|
import BomBrowser from '../components/BomBrowser';
|
||||||
|
import MiningView from '../components/MiningView';
|
||||||
|
import SimilarityView from '../components/SimilarityView';
|
||||||
|
import StandardLibrary from '../components/StandardLibrary';
|
||||||
|
import ReuseSearch from '../components/ReuseSearch';
|
||||||
|
import ToastContainer from '../components/Toast';
|
||||||
|
import SidePanelHost, { closeSidePanel } from '../components/SidePanel';
|
||||||
|
import LoadingScreen from '../components/LoadingScreen';
|
||||||
|
import { IconBox, IconNetwork, IconCompare, IconLayers, IconTarget } from '../components/Icons';
|
||||||
|
import '../styles.css';
|
||||||
|
|
||||||
|
const VIEWS = [
|
||||||
|
{ key: 'bom-browser', label: '产品BOM库', icon: IconBox },
|
||||||
|
{ key: 'mining-view', label: '公共组件挖掘', icon: IconNetwork },
|
||||||
|
{ key: 'standard-library', label: '标准组件库', icon: IconLayers },
|
||||||
|
{ key: 'similarity-view', label: '产品相似度', icon: IconCompare },
|
||||||
|
{ key: 'reuse-search', label: '新需求复用', icon: IconTarget },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATA_SOURCES = [
|
||||||
|
{ key: 'compact-10k', label: '10K 模拟数据', url: '/bomx/bom-data.json' },
|
||||||
|
{ key: 'real-137', label: '137 真实数据', url: '/bomx/bom-data-real.json' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOAD_STAGES = [
|
||||||
|
{ key: 'download', label: '下载数据', icon: '↓' },
|
||||||
|
{ key: 'parse', label: '解析 JSON', icon: '{}' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BomxReuseHome() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [current, setCurrent] = useState('bom-browser');
|
||||||
|
const [miningResult, setMiningResult] = useState<any[]>([]);
|
||||||
|
const [thresholds, setThresholds] = useState<any>({ minCoveragePct: 0.5, minNodes: 3 });
|
||||||
|
const [data, setData] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dataSource, setDataSource] = useState('compact-10k');
|
||||||
|
|
||||||
|
// LoadingScreen 状态
|
||||||
|
const [phase, setPhase] = useState('download');
|
||||||
|
const [stageStatus, setStageStatus] = useState<Record<string, any>>({});
|
||||||
|
const [stageDetail, setStageDetail] = useState<Record<string, any>>({});
|
||||||
|
const [totalBytes, setTotalBytes] = useState(0);
|
||||||
|
const [loadError, setLoadError] = useState<any>(null);
|
||||||
|
const [fadingOut, setFadingOut] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async (sourceKey: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setFadingOut(false);
|
||||||
|
setLoadError(null);
|
||||||
|
setMiningResult([]);
|
||||||
|
setData(null);
|
||||||
|
setStageStatus({});
|
||||||
|
setStageDetail({});
|
||||||
|
setTotalBytes(0);
|
||||||
|
setPhase('download');
|
||||||
|
|
||||||
|
const source = DATA_SOURCES.find(s => s.key === sourceKey);
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 阶段 1:下载数据
|
||||||
|
setPhase('download');
|
||||||
|
const res = await fetch(source.url);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const contentLength = parseInt(res.headers.get('content-length') || '0', 10);
|
||||||
|
setTotalBytes(contentLength);
|
||||||
|
setStageStatus(prev => ({ ...prev, download: 'done' }));
|
||||||
|
setStageDetail(prev => ({
|
||||||
|
...prev,
|
||||||
|
download: contentLength ? `${(contentLength / 1024 / 1024).toFixed(1)} MB` : '完成',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 阶段 2:解析 JSON
|
||||||
|
setPhase('parse');
|
||||||
|
const bomData = await res.json();
|
||||||
|
setStageStatus(prev => ({ ...prev, parse: 'done' }));
|
||||||
|
setStageDetail(prev => ({ ...prev, parse: `${bomData.products.length} 个产品` }));
|
||||||
|
|
||||||
|
setData(bomData);
|
||||||
|
|
||||||
|
// 渐隐过渡
|
||||||
|
setFadingOut(true);
|
||||||
|
await new Promise(r => setTimeout(r, 350));
|
||||||
|
setLoading(false);
|
||||||
|
setFadingOut(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
setLoadError(e.message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initStore();
|
||||||
|
loadData(dataSource);
|
||||||
|
}, [dataSource, loadData]);
|
||||||
|
|
||||||
|
const products = data?.products || [];
|
||||||
|
const newRequirements = data?.newRequirements || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bomx-app">
|
||||||
|
<header className="app-header">
|
||||||
|
<span className="app-logo"><img src="/bomx/logo.png" alt="logo" /></span>
|
||||||
|
<h1 className="app-title">企业级BOM标准化治理</h1>
|
||||||
|
<nav className="app-nav">
|
||||||
|
{VIEWS.map(v => {
|
||||||
|
const Icon = v.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v.key}
|
||||||
|
className={`nav-btn ${current === v.key ? 'active' : ''}`}
|
||||||
|
onClick={() => { setCurrent(v.key); closeSidePanel(); }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<span className="nav-icon"><Icon /></span>
|
||||||
|
{v.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto', flexShrink: 0,
|
||||||
|
padding: '6px 14px', borderRadius: 8, cursor: 'pointer',
|
||||||
|
border: '1px solid #4F46E5',
|
||||||
|
background: 'rgba(99,102,241,0.08)',
|
||||||
|
color: '#4F46E5', fontSize: 13, fontWeight: 600,
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(99,102,241,0.18)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(99,102,241,0.08)'; }}
|
||||||
|
>
|
||||||
|
← 返回平台
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="app-body">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingScreen
|
||||||
|
phase={phase}
|
||||||
|
stageStatus={stageStatus}
|
||||||
|
stageDetail={stageDetail}
|
||||||
|
totalBytes={totalBytes}
|
||||||
|
error={loadError}
|
||||||
|
onRetry={() => loadData(dataSource)}
|
||||||
|
fadingOut={fadingOut}
|
||||||
|
stages={LOAD_STAGES}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<main id="view-container" key={current}>
|
||||||
|
{current === 'bom-browser' && <BomBrowser products={products} />}
|
||||||
|
{current === 'mining-view' && <MiningView products={products} miningResult={miningResult} setMiningResult={setMiningResult} thresholds={thresholds} setThresholds={setThresholds} />}
|
||||||
|
{current === 'similarity-view' && <SimilarityView products={products} miningResult={miningResult} />}
|
||||||
|
{current === 'standard-library' && <StandardLibrary />}
|
||||||
|
{current === 'reuse-search' && <ReuseSearch newRequirements={newRequirements} />}
|
||||||
|
</main>
|
||||||
|
<SidePanelHost />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ToastContainer />
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div className="data-source-switch">
|
||||||
|
<select
|
||||||
|
value={dataSource}
|
||||||
|
onChange={e => setDataSource(e.target.value)}
|
||||||
|
className="data-source-select"
|
||||||
|
>
|
||||||
|
{DATA_SOURCES.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import type { Order } from '../data/orders';
|
||||||
|
import { useTheme } from '@/shared/context/ThemeContext';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: number;
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
thinking: string;
|
||||||
|
content: string;
|
||||||
|
thinkingDone: boolean;
|
||||||
|
done: boolean;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BottomCostBarProps {
|
||||||
|
selectedOrder: Order | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeStr = () => {
|
||||||
|
const now = new Date();
|
||||||
|
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUICK_CMDS = ['分析成本偏差', '报价建议', '降本措施', '毛利对比', '亏损原因', '同品类对比'];
|
||||||
|
|
||||||
|
function fmt(n: number) { return Math.round(n).toLocaleString('zh-CN'); }
|
||||||
|
|
||||||
|
export default function BottomCostBar({ selectedOrder }: BottomCostBarProps) {
|
||||||
|
const { tokens } = useTheme();
|
||||||
|
const [messages, setMessages] = useState<Message[]>([{
|
||||||
|
id: 0, role: 'ai',
|
||||||
|
thinking: '',
|
||||||
|
content: '您好!我是 AI 订单成本分析助手。\n\n我可以帮您:\n• 分析订单三种成本口径(标准/采购/领用)的偏差原因\n• 识别成本异常项并给出降本建议\n• 对比出货收入与各维度成本,计算毛利与净利润\n• 将领用成本与报价成本细项做对比,辅助销售报价决策\n• 追踪订单结单过程中数据偏差或失真问题\n\n请从左侧选择一个订单,或直接提问,例如:\n"为什么ORD003亏损?" / "如何降低面料采购成本?"',
|
||||||
|
thinkingDone: true, done: true, time: getTimeStr(),
|
||||||
|
}]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}, [messages, isTyping]);
|
||||||
|
|
||||||
|
const streamText = useCallback((msgId: number, field: 'thinking' | 'content', fullText: string, charDelay: number, onDone?: () => void) => {
|
||||||
|
let idx = 0;
|
||||||
|
const tick = () => {
|
||||||
|
idx += Math.floor(Math.random() * 3) + 1;
|
||||||
|
const slice = fullText.slice(0, Math.min(idx, fullText.length));
|
||||||
|
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, [field]: slice } : m));
|
||||||
|
if (idx < fullText.length) {
|
||||||
|
streamTimerRef.current = setTimeout(tick, charDelay);
|
||||||
|
} else {
|
||||||
|
if (field === 'thinking') setMessages(prev => prev.map(m => m.id === msgId ? { ...m, thinkingDone: true } : m));
|
||||||
|
else setMessages(prev => prev.map(m => m.id === msgId ? { ...m, done: true } : m));
|
||||||
|
onDone?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
streamTimerRef.current = setTimeout(tick, charDelay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildReply = useCallback((text: string): { thinking: string; content: string } => {
|
||||||
|
const o = selectedOrder;
|
||||||
|
|
||||||
|
if (text.includes('偏差') || text.includes('异常') || text.includes('差异')) {
|
||||||
|
if (!o) return {
|
||||||
|
thinking: '分析成本偏差指标…',
|
||||||
|
content: '请先选择一个订单,我将为您详细分析该订单的标准成本、采购成本和领用成本之间的偏差。',
|
||||||
|
};
|
||||||
|
const stdRow = o.rows.find(r => r.type === '标准成本')!;
|
||||||
|
const purRow = o.rows.find(r => r.type === '采购成本')!;
|
||||||
|
const useRow = o.rows.find(r => r.type === '领用成本')!;
|
||||||
|
const purDiff = purRow.totalCost - stdRow.totalCost;
|
||||||
|
const useDiff = useRow.totalCost - stdRow.totalCost;
|
||||||
|
return {
|
||||||
|
thinking: `调取订单 ${o.orderNo} 的三维成本数据…\n计算采购成本 vs 标准成本偏差…\n计算领用成本 vs 标准成本偏差…\n识别关键异常项…`,
|
||||||
|
content: `订单 ${o.orderNo} · ${o.customer} 成本偏差分析\n\n一、采购成本 vs 标准成本\n• 总偏差:${purDiff >= 0 ? '+' : ''}${fmt(purDiff)} 元(${(purDiff / stdRow.totalCost * 100).toFixed(1)}%)\n• 直接材料偏差:${fmt(purRow.directMaterial - stdRow.directMaterial)} 元\n• 直接人工偏差:${fmt(purRow.directLabor - stdRow.directLabor)} 元\n• 采购偏差${Math.abs(purDiff) > 5000 ? '较大,需关注供应商价格执行情况' : '在合理范围内'}\n\n二、领用成本 vs 标准成本\n• 总偏差:${useDiff >= 0 ? '+' : ''}${fmt(useDiff)} 元(${(useDiff / stdRow.totalCost * 100).toFixed(1)}%)\n• 直接材料领用偏差:${fmt(useRow.directMaterial - stdRow.directMaterial)} 元\n• 直接人工偏差:${fmt(useRow.directLabor - stdRow.directLabor)} 元\n${useDiff > 0 ? '• 领用成本超标,需核查:①领料单与实际用量是否一致;②生产废料率是否偏高;③工时核算是否准确' : '• 领用成本节约,生产效率优于标准,建议更新标准成本'}\n\n${o.note || '建议对偏差超过标准5%的科目进行专项核查。'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('亏损') || text.includes('原因')) {
|
||||||
|
if (!o) return {
|
||||||
|
thinking: '分析亏损原因…',
|
||||||
|
content: '请先选择一个订单进行亏损分析。您可以在左侧选择风险等级为"危险"的订单进行重点分析。',
|
||||||
|
};
|
||||||
|
const useRow = o.rows.find(r => r.type === '领用成本')!;
|
||||||
|
const stdRow = o.rows.find(r => r.type === '标准成本')!;
|
||||||
|
return {
|
||||||
|
thinking: `分析订单 ${o.orderNo} 亏损成因…\n对比三维成本数据…\n识别关键亏损驱动因素…`,
|
||||||
|
content: `订单 ${o.orderNo} · ${o.customer} ${useRow.netProfit < 0 ? '亏损原因分析' : '利润分析'}\n\n盈利概况(领用成本口径):\n• 销售收入:${fmt(useRow.revenue)} 元\n• 实际制造成本:${fmt(useRow.totalCost)} 元(毛利率 ${(useRow.grossMargin * 100).toFixed(1)}%)\n• 管销研费用:${fmt(useRow.mgmtExpense)} 元\n• 净利润:${fmt(useRow.netProfit)} 元(净利率 ${(useRow.netMargin * 100).toFixed(1)}%)\n\n${useRow.netProfit < 0 ? `亏损原因分析:\n① 报价偏低:销售单价 ¥${o.rows[0].unitPrice},对应制造成本摊分后利润空间不足\n② 实际领用成本(${fmt(useRow.totalCost)} 元)超出标准成本(${fmt(stdRow.totalCost)} 元)${fmt(useRow.totalCost - stdRow.totalCost)} 元\n③ 管销研费用固定支出(${fmt(useRow.mgmtExpense)} 元)较高,压缩了净利润空间\n\n建议措施:\n• 下期同类订单报价上调至少 ${((0.08 - useRow.netMargin) * 100).toFixed(0)}% 以确保5%+净利润空间\n• 重点核查领料损耗率,目标降低领用成本至采购成本±2%范围内` : `订单盈利健康,净利率${(useRow.netMargin * 100).toFixed(1)}%处于${useRow.netMargin >= 0.1 ? '良好' : '偏低'}水平。`}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('降本') || text.includes('降低成本') || text.includes('措施')) {
|
||||||
|
return {
|
||||||
|
thinking: `分析制造业成本降低路径…\n结合订单成本结构…\n生成降本措施建议…`,
|
||||||
|
content: `${o ? `${o.customer} 订单降本措施` : '制造业订单降本措施'}\n\n一、直接材料降本\n• 采购集中度:同品类面料、辅料集中批量采购,争取量价优惠\n• 供应商竞标:对偏差超5%的物料实施双源采购,引入竞争机制\n• 领用管控:建立领料配额制度,减少超额领料,控制废料率\n• 标准修订:定期与实际用量对比,优化单件用料标准\n\n二、直接人工降本\n• 工时分析:对比标准工时与实际工时,识别效率洼地\n• 工序优化:推进精益生产,减少等待、搬运等非增值作业\n• 技能培训:提升操作工艺水平,降低次品率与返工成本\n\n三、制造费用优化\n• 设备利用率:提升产线利用率,分摊单件制造费用\n• 能耗管理:实施能源计量与节能改造\n• 维修费控制:推行预防性维护,减少突发停机\n\n四、标准成本更新\n• 每季度复核标准成本,确保标准与实际接轨,减少无效偏差分析`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('报价') || text.includes('定价')) {
|
||||||
|
if (!o) return {
|
||||||
|
thinking: '分析报价策略…',
|
||||||
|
content: '请选择订单后,我将基于该订单的实际成本数据为您提供报价优化建议。',
|
||||||
|
};
|
||||||
|
const useRow = o.rows.find(r => r.type === '领用成本')!;
|
||||||
|
const targetMargin = 0.08;
|
||||||
|
const suggestedRevenue = useRow.totalCost + useRow.mgmtExpense;
|
||||||
|
const suggestedRevenueWithTarget = suggestedRevenue / (1 - targetMargin);
|
||||||
|
const suggestedUnitPrice = suggestedRevenueWithTarget / useRow.qty;
|
||||||
|
return {
|
||||||
|
thinking: `基于领用成本口径计算合理报价区间…\n设定目标净利率 8%…\n生成报价建议…`,
|
||||||
|
content: `${o.customer} · ${o.productCategory} 报价优化建议\n\n当前报价:¥${o.rows[0].unitPrice} / 件\n当前净利率:${(useRow.netMargin * 100).toFixed(1)}%(领用成本口径)\n\n目标定价测算(目标净利率 8%):\n• 领用制造成本:${fmt(useRow.totalCost)} 元\n• 管销研费用:${fmt(useRow.mgmtExpense)} 元\n• 目标收入:${fmt(Math.round(suggestedRevenueWithTarget))} 元\n• 建议单价:¥${suggestedUnitPrice.toFixed(4)} / 件(当前 +${((suggestedUnitPrice / o.rows[0].unitPrice - 1) * 100).toFixed(1)}%)\n\n报价策略建议:\n① 对利润率已低于5%的${o.channel}订单,下期谈判时争取调价空间\n② 可将采购成本节省部分(${fmt(o.rows.find(r => r.type === '采购成本')!.totalCost - useRow.totalCost)}元)纳入报价谈判筹码\n③ 建立"成本+目标利润"的报价底线机制,避免低于保本价接单`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('毛利') || text.includes('对比')) {
|
||||||
|
return {
|
||||||
|
thinking: `汇总全部订单毛利数据…\n按客户/渠道/品类分组…\n生成对比分析…`,
|
||||||
|
content: `全部订单毛利对比(领用成本口径)\n\n${ORDERS_SUMMARY}\n\n整体建议:\n• 高毛利订单(≥15%):日本Hakuba、百联传媒,可作为定价标杆\n• 预警订单(5%-15%):ATB、PhotoPro、Amazon US,重点监控成本偏差\n• 亏损/危险订单:万博传媒(领用成本亏损)、Kenko Korea(三维成本均亏),需专项降本或调整报价\n\n渠道盈利对比:\n• 外销-B2B平均净利率最高,但日韩绿幕品类定价偏低\n• 内销直销华南区风险最大(领用成本超标严重)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用回复
|
||||||
|
return {
|
||||||
|
thinking: `解析问题关键词…\n结合订单成本分析专业知识…\n生成回答…`,
|
||||||
|
content: `${o ? `当前订单:${o.customer}(${o.orderNo})
|
||||||
|
品类:${o.productCategory} | 渠道:${o.channel}
|
||||||
|
|
||||||
|
` : ''}您询问了关于:"${text}"
|
||||||
|
|
||||||
|
建议使用快捷指令获取更精准的分析:
|
||||||
|
• "分析成本偏差" — 查看三维成本的差异明细
|
||||||
|
• "亏损原因" — 分析净利润为负的根本原因
|
||||||
|
• "报价建议" — 基于实际成本给出定价优化方案
|
||||||
|
• "降本措施" — 获取系统性运营降本建议
|
||||||
|
• "毛利对比" — 对比所有订单盈利情况
|
||||||
|
|
||||||
|
或直接在左侧点击选择订单,中间区域会展示完整的盈利能力分析表。`,
|
||||||
|
};
|
||||||
|
}, [selectedOrder]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback((text: string) => {
|
||||||
|
if (!text.trim() || isTyping) return;
|
||||||
|
setMessages(prev => [...prev, { id: Date.now(), role: 'user', thinking: '', content: text, thinkingDone: true, done: true, time: getTimeStr() }]);
|
||||||
|
setInput('');
|
||||||
|
setIsTyping(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const { thinking, content } = buildReply(text);
|
||||||
|
const aiMsgId = Date.now() + 1;
|
||||||
|
setMessages(prev => [...prev, { id: aiMsgId, role: 'ai', thinking: '', content: '', thinkingDone: false, done: false, time: getTimeStr() }]);
|
||||||
|
streamText(aiMsgId, 'thinking', thinking, 55, () => {
|
||||||
|
setTimeout(() => { streamText(aiMsgId, 'content', content, 35, () => setIsTyping(false)); }, 500);
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
}, [isTyping, buildReply, streamText]);
|
||||||
|
|
||||||
|
const chatHeight = expanded ? 380 : 90;
|
||||||
|
const ARC_H = 60;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-up" style={{ flexShrink: 0, paddingLeft: '295px', paddingRight: '310px', paddingBottom: 0, position: 'relative', overflow: 'visible' }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{/* 弧形顶部 */}
|
||||||
|
<svg viewBox="0 0 1000 110" preserveAspectRatio="none" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: ARC_H, pointerEvents: 'none', zIndex: 1, display: 'block', overflow: 'visible' }}>
|
||||||
|
<path d="M 0,110 C 150,-40 850,-40 1000,110 L 1000,110 L 0,110 Z" fill={tokens.bottomArcFill} />
|
||||||
|
<path d="M 0,110 C 150,-40 850,-40 1000,110" fill="none" stroke={tokens.svgStroke} strokeWidth="1.2" />
|
||||||
|
<circle cx="0" cy="110" r="5" fill={tokens.svgDot} />
|
||||||
|
<circle cx="1000" cy="110" r="5" fill={tokens.svgDot} />
|
||||||
|
<circle cx="500" cy="2" r="3" fill={tokens.svgStroke} />
|
||||||
|
<circle cx="200" cy="35" r="2" fill={tokens.svgStroke} />
|
||||||
|
<circle cx="800" cy="35" r="2" fill={tokens.svgStroke} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{ position: 'absolute', top: ARC_H - 1, left: 0, right: 0, bottom: 0, zIndex: 0, background: tokens.bottomBg, backdropFilter: 'blur(18px)', WebkitBackdropFilter: 'blur(18px)' }} />
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', zIndex: 2, paddingTop: ARC_H }}>
|
||||||
|
{/* Tab 栏 */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, padding: '6px 20px 0', borderBottom: `1px solid ${tokens.borderAccent}33` }}>
|
||||||
|
<button style={{
|
||||||
|
padding: '6px 16px', borderRadius: '8px 8px 0 0', fontSize: 16, fontWeight: 700, cursor: 'pointer',
|
||||||
|
background: tokens.accentBg, color: tokens.accent,
|
||||||
|
borderBottom: `2px solid ${tokens.accent}`,
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
|
<circle cx="9" cy="9" r="7" stroke={tokens.accent} strokeWidth="1.5"/>
|
||||||
|
<path d="M 6,7 L 12,7 M 6,9 L 12,9 M 6,11 L 10,11" stroke={tokens.accent} strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
AI 智能问答
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 6 }}>
|
||||||
|
{selectedOrder && (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: tokens.accentBg, border: `1px solid ${tokens.borderAccent}`, color: tokens.accent, fontSize: 12 }}>
|
||||||
|
{selectedOrder.customer} · {selectedOrder.productCategory}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setExpanded(v => !v)} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 7, border: `1px solid ${tokens.border}`,
|
||||||
|
background: 'transparent', color: tokens.textMuted, fontSize: 12, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{expanded ? '收起' : '展开'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 对话区 */}
|
||||||
|
<div ref={scrollRef} className="space-scroll" style={{ height: chatHeight, overflowY: 'auto', padding: '8px 24px 4px', transition: 'height 0.25s ease' }}>
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} style={{ display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start', marginBottom: 6 }}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<span style={{ width: 28, height: 28, borderRadius: '50%', background: tokens.panelBgAlt, border: `1px solid ${tokens.borderAccent}`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginRight: 7, marginTop: 2 }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="5" width="3" height="6" rx="1" fill={tokens.accent} opacity="0.8"/><rect x="6.5" y="3" width="3" height="10" rx="1" fill={tokens.accent}/><rect x="11" y="6" width="3" height="4" rx="1" fill={tokens.accent} opacity="0.8"/></svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div style={{ maxWidth: '82%' }}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<>
|
||||||
|
{msg.thinking && (
|
||||||
|
<div style={{ padding: '4px 10px', borderRadius: '9px 9px 9px 2px', background: tokens.panelBgAlt, border: `1px solid ${tokens.border}`, marginBottom: msg.thinkingDone && msg.content ? 5 : 0 }}>
|
||||||
|
<span style={{ fontStyle: 'italic', color: tokens.textMuted, fontSize: 14, lineHeight: 1.65, whiteSpace: 'pre-line', display: 'block' }}>
|
||||||
|
{!msg.thinkingDone && <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: tokens.textMuted, marginRight: 5, verticalAlign: 'middle', animation: 'space-pulse 0.8s ease-in-out infinite' }} />}
|
||||||
|
{msg.thinking}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg.thinkingDone && msg.content && (
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '2px 12px 12px 12px', background: tokens.centerBg, border: `1px solid ${tokens.border}`, color: tokens.text, fontSize: 15, lineHeight: 1.65, whiteSpace: 'pre-line' }}>
|
||||||
|
{msg.content}
|
||||||
|
{!msg.done && <span style={{ display: 'inline-block', width: 2, height: '1em', background: tokens.accent, marginLeft: 2, verticalAlign: 'text-bottom', animation: 'space-pulse 0.6s ease-in-out infinite' }} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '12px 12px 2px 12px', background: tokens.accentBg, border: `1px solid ${tokens.accent}33`, color: tokens.text, fontSize: 15, lineHeight: 1.65, whiteSpace: 'pre-line' }}>{msg.content}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 12, marginTop: 2, textAlign: msg.role === 'user' ? 'right' : 'left' }}>{msg.time}</div>
|
||||||
|
</div>
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<span style={{ width: 26, height: 26, borderRadius: '50%', background: tokens.accentBg, border: `1px solid ${tokens.accent}55`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginLeft: 7, marginTop: 2 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="5" r="3" stroke={tokens.accent} strokeWidth="1.2"/><path d="M 2,12 C 2,9.5 4.5,8 7,8 C 9.5,8 12,9.5 12,12" stroke={tokens.accent} strokeWidth="1.2" strokeLinecap="round"/></svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isTyping && messages[messages.length - 1]?.role !== 'ai' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||||
|
<span style={{ width: 28, height: 28, borderRadius: '50%', background: tokens.panelBgAlt, border: `1px solid ${tokens.borderAccent}`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="5" width="3" height="6" rx="1" fill={tokens.accent} opacity="0.8"/><rect x="6.5" y="3" width="3" height="10" rx="1" fill={tokens.accent}/><rect x="11" y="6" width="3" height="4" rx="1" fill={tokens.accent} opacity="0.8"/></svg>
|
||||||
|
</span>
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '12px 12px 12px 2px', background: tokens.centerBg, border: `1px solid ${tokens.border}`, display: 'flex', gap: 5, alignItems: 'center' }}>
|
||||||
|
{[0, 1, 2].map(i => <span key={i} style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: tokens.accent, animation: `space-pulse 1s ${i * 0.2}s ease-in-out infinite` }} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<div style={{ padding: '4px 24px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 7, flexWrap: 'wrap' }}>
|
||||||
|
{QUICK_CMDS.map(cmd => (
|
||||||
|
<button key={cmd} onClick={() => sendMessage(cmd)} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 18, border: `1px solid ${tokens.borderAccent}`, background: tokens.accentBg, color: tokens.textSub, fontSize: 12, cursor: 'pointer', transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = tokens.tableRowHover; e.currentTarget.style.borderColor = tokens.accent; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = tokens.accentBg; e.currentTarget.style.borderColor = tokens.borderAccent; }}
|
||||||
|
>{cmd}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(input); } }}
|
||||||
|
placeholder={selectedOrder ? `询问关于 ${selectedOrder.customer} 订单的成本问题…` : '输入问题,如:为什么这个订单亏损?如何降低面料成本?'}
|
||||||
|
style={{ flex: 1, padding: '9px 16px', borderRadius: 22, background: tokens.panelBg, border: `1px solid ${tokens.borderAccent}`, color: tokens.text, fontSize: 13, outline: 'none', transition: 'border-color 0.15s ease' }}
|
||||||
|
onFocus={e => (e.target.style.borderColor = tokens.accent)}
|
||||||
|
onBlur={e => (e.target.style.borderColor = tokens.borderAccent)}
|
||||||
|
/>
|
||||||
|
<button onClick={() => sendMessage(input)} disabled={!input.trim() || isTyping} style={{
|
||||||
|
width: 40, height: 40, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: input.trim() && !isTyping ? `linear-gradient(135deg, ${tokens.accent}, ${tokens.accentGlow})` : tokens.panelBgAlt,
|
||||||
|
border: `1px solid ${tokens.borderAccent}`, color: input.trim() && !isTyping ? tokens.textInverse : tokens.textMuted,
|
||||||
|
fontSize: 17, cursor: input.trim() && !isTyping ? 'pointer' : 'not-allowed',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: input.trim() && !isTyping ? '0 0 12px rgba(0,170,255,0.4)' : 'none',
|
||||||
|
}}>
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none"><path d="M 2,15 L 15,8.5 L 2,2 L 2,7 L 10,8.5 L 2,10 Z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静态汇总文本
|
||||||
|
const ORDERS_SUMMARY = `客户 | 品类 | 净利率(领用)
|
||||||
|
Hakuba JP | 无缝背景纸 | +25.9% ✅
|
||||||
|
百联传媒集团 | 直播背景墙 | +16.7% ✅
|
||||||
|
Studio Gear EU | 便携背景架 | +8.3% ✅
|
||||||
|
PhotoPro Inc. | 影棚背景布 | +9.7% ✅
|
||||||
|
ATB | 摄影幕布 | +7.0% ⚠️
|
||||||
|
Amazon US | 摄影幕布套装| +1.9% ⚠️
|
||||||
|
Kenko Korea | 绿幕套装 | -1.5% 🚨
|
||||||
|
万博传媒科技 | 摄影幕布 | -7.2% 🚨`;
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
import { type Order } from '../data/orders';
|
||||||
|
import type { DragEvent } from 'react';
|
||||||
|
import { useTheme } from '@/shared/context/ThemeContext';
|
||||||
|
|
||||||
|
interface CenterProfitTableProps {
|
||||||
|
order: Order | null;
|
||||||
|
resetKey?: number;
|
||||||
|
onDrop?: (event: DragEvent<HTMLDivElement>) => void;
|
||||||
|
isProcessing?: boolean;
|
||||||
|
processingOrder?: Order | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number, decimals = 0) {
|
||||||
|
if (decimals > 0) return n.toLocaleString('zh-CN', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||||
|
return Math.round(n).toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(n: number) {
|
||||||
|
const sign = n > 0 ? '+' : '';
|
||||||
|
return `${sign}${(n * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorPct(n: number, textSub: string, danger: string, success: string) {
|
||||||
|
if (n > 0.01) return danger;
|
||||||
|
if (n < -0.005) return success;
|
||||||
|
return textSub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorMargin(n: number, success: string, warning: string, danger: string) {
|
||||||
|
if (n >= 0.15) return success;
|
||||||
|
if (n >= 0.05) return warning;
|
||||||
|
return danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 空状态工作区 ─────────────────────────────────────────────────────────────
|
||||||
|
function EmptyState({ onDrop }: { onDrop?: (event: DragEvent<HTMLDivElement>) => void }) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
// 浅色模式三种成本类型统一用蓝色调,用深浅区分
|
||||||
|
const costColors = isLight
|
||||||
|
? ['#1e40af', '#2563eb', '#3b82f6']
|
||||||
|
: ['#7b61ff', '#00d4ff', '#00e599'];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={e => { e.preventDefault(); e.currentTarget.style.background = tokens.accentBg; }}
|
||||||
|
onDragLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
onDrop={e => { e.currentTarget.style.background = 'transparent'; onDrop?.(e); }}
|
||||||
|
style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 20, padding: 40, transition: 'background 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 订单盈利能力分析表图示 */}
|
||||||
|
<div style={{
|
||||||
|
width: 280, border: `1px solid ${tokens.border}`,
|
||||||
|
borderRadius: 12, padding: '16px 20px',
|
||||||
|
background: tokens.panelBg,
|
||||||
|
}}>
|
||||||
|
<div style={{ color: tokens.textSub, fontSize: 15, fontWeight: 700, textAlign: 'center', marginBottom: 10 }}>订单盈利能力分析表</div>
|
||||||
|
{['\u6807\u51c6\u6210\u672c', '\u91c7\u8d2d\u6210\u672c', '\u9886\u7528\u6210\u672c'].map((type, i) => {
|
||||||
|
const colors = costColors;
|
||||||
|
return (
|
||||||
|
<div key={type} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: colors[i], flexShrink: 0 }} />
|
||||||
|
<span style={{ color: tokens.textMuted, fontSize: 14 }}>{type}</span>
|
||||||
|
<div style={{ flex: 1, height: 4, borderRadius: 2, background: `${colors[i]}22`, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${[60, 75, 80][i]}%`, height: '100%', background: colors[i], borderRadius: 2, opacity: 0.6 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ marginTop: 8, paddingTop: 8, borderTop: `1px solid ${tokens.border}`, color: tokens.textMuted, fontSize: 13, textAlign: 'center' }}>
|
||||||
|
含成本偏差分析 · 报价对比
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ color: tokens.textSub, fontSize: 20, fontWeight: 700, marginBottom: 8 }}>
|
||||||
|
订单盈利能力分析
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 16, lineHeight: 1.7, marginBottom: 12 }}>
|
||||||
|
拖拽左侧订单卡片到此处<br />
|
||||||
|
生成订单盈利能力分析表
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.accent, fontSize: 15, padding: '6px 14px', borderRadius: 20, border: `1px dashed ${tokens.accent}55`, display: 'inline-block' }}>
|
||||||
|
或点击左侧订单列表查看详情
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 主体分析表 ────────────────────────────────────────────────────────────────
|
||||||
|
function ProfitTable({ order }: { order: Order }) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const costColors = isLight
|
||||||
|
? ['#1e40af', '#2563eb', '#3b82f6'] // 浅色:标准=海军蓝,采购=皮尔蓝,领用=天蓝
|
||||||
|
: ['#7b61ff', '#00d4ff', '#00e599'];
|
||||||
|
const stdRow = order.rows.find(r => r.type === '标准成本')!;
|
||||||
|
const purRow = order.rows.find(r => r.type === '采购成本')!;
|
||||||
|
const useRow = order.rows.find(r => r.type === '领用成本')!;
|
||||||
|
|
||||||
|
const headerCell = (text: string, sub?: string, colspan?: number, bg?: string) => {
|
||||||
|
// 浅色模式下第一级表头(大组)用深层蓝色系,第二级用浅蓝色系
|
||||||
|
const isSectionHeader = !!bg;
|
||||||
|
return (
|
||||||
|
<th colSpan={colspan} style={{
|
||||||
|
padding: isSectionHeader ? '8px 10px' : '6px 8px',
|
||||||
|
fontSize: isSectionHeader ? 12 : 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: isLight
|
||||||
|
? (isSectionHeader ? 'transparent' : 'rgba(30,60,130,0.05)')
|
||||||
|
: (bg || tokens.tableHeaderBg),
|
||||||
|
color: isLight
|
||||||
|
? (isSectionHeader ? 'rgba(255,255,255,0.95)' : tokens.textSub)
|
||||||
|
: tokens.textSub,
|
||||||
|
borderBottom: isLight
|
||||||
|
? (isSectionHeader ? '1px solid rgba(255,255,255,0.15)' : `2px solid ${tokens.accent}30`)
|
||||||
|
: `1px solid ${tokens.border}`,
|
||||||
|
borderRight: `1px solid ${isLight ? (isSectionHeader ? 'rgba(255,255,255,0.12)' : 'rgba(30,60,130,0.1)') : tokens.borderSub}`,
|
||||||
|
borderLeft: `1px solid ${isLight ? (isSectionHeader ? 'rgba(255,255,255,0.12)' : 'rgba(30,60,130,0.1)') : tokens.borderSub}`,
|
||||||
|
borderTop: `1px solid ${isLight ? (isSectionHeader ? 'rgba(255,255,255,0.1)' : 'rgba(30,60,130,0.1)') : tokens.borderSub}`,
|
||||||
|
textAlign: 'center', whiteSpace: 'nowrap',
|
||||||
|
letterSpacing: isSectionHeader ? '0.06em' : '0',
|
||||||
|
}}>
|
||||||
|
{text}{sub && <div style={{ fontSize: 10, fontWeight: 400, color: isLight && isSectionHeader ? 'rgba(255,255,255,0.75)' : tokens.textMuted, marginTop: 1 }}>{sub}</div>}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataCell = (val: string | number, color?: string, align: 'left' | 'center' | 'right' = 'right', bold?: boolean, isKey?: boolean) => (
|
||||||
|
<td style={{
|
||||||
|
padding: '7px 10px', fontSize: 12,
|
||||||
|
color: color || tokens.text,
|
||||||
|
fontWeight: bold ? 700 : 400,
|
||||||
|
textAlign: align,
|
||||||
|
borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderLeft: `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderTop: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
background: isKey && isLight ? 'rgba(26,86,219,0.03)' : undefined,
|
||||||
|
}}>{val}</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 成本类型圆形标识点
|
||||||
|
const costDot = (color: string) => (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: color, marginRight: 6, flexShrink: 0,
|
||||||
|
boxShadow: isLight ? 'none' : `0 0 4px ${color}80`,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRow = (row: typeof stdRow, color: string, isHighlight?: boolean) => (
|
||||||
|
<tr style={{
|
||||||
|
background: isLight
|
||||||
|
? (isHighlight ? 'rgba(26,86,219,0.04)' : '#ffffff')
|
||||||
|
: (isHighlight ? `${color}08` : 'transparent'),
|
||||||
|
transition: 'background 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = isLight ? 'rgba(26,86,219,0.07)' : tokens.tableRowHover; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = isLight ? (isHighlight ? 'rgba(26,86,219,0.04)' : '#ffffff') : (isHighlight ? `${color}08` : 'transparent'); }}
|
||||||
|
>
|
||||||
|
<td style={{
|
||||||
|
padding: '7px 10px',
|
||||||
|
borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderLeft: isLight ? `3px solid ${color}` : `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderTop: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
||||||
|
{!isLight && costDot(color)}
|
||||||
|
<span style={{ color, fontSize: 12, fontWeight: 700 }}>{row.type}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{dataCell(fmt(row.qty))}
|
||||||
|
{dataCell(fmt(row.unitPrice, 4))}
|
||||||
|
{dataCell(fmt(row.revenue), tokens.textSub, 'right', true, true)}
|
||||||
|
{dataCell(fmt(row.directMaterial))}
|
||||||
|
{dataCell(fmt(row.directLabor))}
|
||||||
|
{dataCell(fmt(row.manufacturingCost))}
|
||||||
|
{dataCell(fmt(row.totalCost), tokens.text, 'right', true, true)}
|
||||||
|
{dataCell(fmt(row.grossProfit), row.grossProfit >= 0 ? tokens.success : tokens.danger, 'right', true)}
|
||||||
|
{dataCell(`${(row.grossMargin * 100).toFixed(2)}%`, colorMargin(row.grossMargin, tokens.success, tokens.warning, tokens.danger), 'right', true)}
|
||||||
|
{dataCell(fmt(row.mgmtExpense, 2))}
|
||||||
|
{dataCell(fmt(row.netProfit), row.netProfit >= 0 ? tokens.success : tokens.danger, 'right', true)}
|
||||||
|
{dataCell(`${(row.netMargin * 100).toFixed(0)}%`, colorMargin(row.netMargin, tokens.success, tokens.warning, tokens.danger), 'right', true)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 偏差行
|
||||||
|
const deviationRow = (label: string, aRow: typeof stdRow, bRow: typeof stdRow, color: string) => {
|
||||||
|
const fields: Array<keyof typeof stdRow> = ['directMaterial', 'directLabor', 'manufacturingCost', 'totalCost', 'grossProfit', 'grossMargin'];
|
||||||
|
const borderStyle = `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`;
|
||||||
|
return (
|
||||||
|
<tr style={{
|
||||||
|
background: isLight ? 'rgba(245,248,255,0.8)' : 'transparent',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}}>
|
||||||
|
<td colSpan={4} style={{
|
||||||
|
padding: '4px 10px 4px 16px',
|
||||||
|
fontSize: 11, color: tokens.textMuted,
|
||||||
|
borderBottom: borderStyle, borderRight: borderStyle,
|
||||||
|
borderLeft: isLight ? `3px solid ${color}40` : borderStyle,
|
||||||
|
borderTop: 'none', textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
<span style={{ color, fontWeight: 700, marginRight: 4 }}>▲</span>{label}
|
||||||
|
</td>
|
||||||
|
{fields.map((f, i) => {
|
||||||
|
const diff = (aRow[f] as number) - (bRow[f] as number);
|
||||||
|
const c = f === 'grossProfit' || f === 'grossMargin'
|
||||||
|
? (diff > 0 ? tokens.success : tokens.danger)
|
||||||
|
: (diff < 0 ? tokens.success : diff > 0 ? tokens.danger : tokens.textMuted);
|
||||||
|
return (
|
||||||
|
<td key={i} style={{
|
||||||
|
padding: '4px 10px', fontSize: 11, color: c, fontStyle: 'italic',
|
||||||
|
textAlign: 'right', fontWeight: 600,
|
||||||
|
borderBottom: borderStyle, borderRight: borderStyle, borderTop: 'none', borderLeft: i === 0 ? borderStyle : 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{f === 'grossMargin' ? pct(diff) : `${diff >= 0 ? '+' : ''}${fmt(diff)}`}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td colSpan={3} style={{ borderBottom: borderStyle, borderRight: borderStyle, borderTop: 'none', borderLeft: 'none' }} />
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 16px' }} className="space-scroll">
|
||||||
|
{/* 订单基本信息 */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 800, color: tokens.text, marginBottom: 4, textAlign: 'center', letterSpacing: '0.06em' }}>
|
||||||
|
订单盈利能力分析表
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: tokens.textMuted, textAlign: 'left', marginBottom: 8 }}>
|
||||||
|
单位:元 币种:人民币
|
||||||
|
</div>
|
||||||
|
{/* 基本信息格 */}
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12,
|
||||||
|
border: isLight ? '1px solid rgba(30,60,130,0.12)' : `1px solid ${tokens.border}`,
|
||||||
|
borderRadius: 6, overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, width: '10%', whiteSpace: 'nowrap' }}>制造令号</td>
|
||||||
|
<td style={{ padding: '6px 10px', color: tokens.accent, fontWeight: 600, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, width: '20%', fontFamily: 'monospace' }}>{order.orderNo}</td>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, width: '8%' }}>客户</td>
|
||||||
|
<td style={{ padding: '6px 10px', color: tokens.text, fontWeight: 600, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, width: '14%' }}>{order.customer}</td>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, width: '8%' }}>业务员</td>
|
||||||
|
<td style={{ padding: '6px 10px', color: tokens.text, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, width: '14%' }}>{order.salesperson}</td>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, width: '8%' }}>渠道</td>
|
||||||
|
<td style={{ padding: '6px 10px', color: tokens.textMuted, fontWeight: 500, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, width: '14%' }}>{order.channel}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}` }}>结单日期</td>
|
||||||
|
<td style={{ padding: '6px 10px', color: tokens.text, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}` }}>{order.closedDate || '未结单'}</td>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}` }}>品类</td>
|
||||||
|
<td colSpan={5} style={{ padding: '6px 10px', color: tokens.text }}>{order.productCategory}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 一、总体情况 */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: tokens.textSub, marginBottom: 6 }}>一、总体情况</div>
|
||||||
|
<div style={{ overflowX: 'auto', borderRadius: 8, overflow: 'hidden', border: isLight ? '1px solid rgba(30,60,130,0.12)' : `1px solid ${tokens.border}`, boxShadow: isLight ? '0 1px 6px rgba(30,60,130,0.06)' : 'none' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(22,56,140,0.88)' : 'transparent' }}>
|
||||||
|
{headerCell('项目', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('订单信息', undefined, 3, 'rgba(0,30,90,0.7)')}
|
||||||
|
{headerCell('制造成本信息', undefined, 4, 'rgba(0,30,90,0.7)')}
|
||||||
|
{headerCell('毛利情况', undefined, 2, 'rgba(0,40,100,0.6)')}
|
||||||
|
{headerCell('管销研', undefined, 1, 'rgba(0,30,90,0.7)')}
|
||||||
|
{headerCell('利润情况', undefined, 2, 'rgba(0,40,100,0.6)')}
|
||||||
|
</tr>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(30,60,130,0.06)' : 'transparent' }}>
|
||||||
|
{headerCell('分类')}
|
||||||
|
{headerCell('发货数量')}
|
||||||
|
{headerCell('单价')}
|
||||||
|
{headerCell('销售收入', '(含税)')}
|
||||||
|
{headerCell('直接材料')}
|
||||||
|
{headerCell('直接人工')}
|
||||||
|
{headerCell('制造费用')}
|
||||||
|
{headerCell('制造成本')}
|
||||||
|
{headerCell('毛利')}
|
||||||
|
{headerCell('毛利率')}
|
||||||
|
{headerCell('管理费用')}
|
||||||
|
{headerCell('净利润')}
|
||||||
|
{headerCell('净利率')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody style={{ background: isLight ? '#ffffff' : 'transparent' }}>
|
||||||
|
{renderRow(stdRow, costColors[0])}
|
||||||
|
{deviationRow('采购成本 vs 标准成本', purRow, stdRow, costColors[1])}
|
||||||
|
{renderRow(purRow, costColors[1])}
|
||||||
|
{deviationRow('领用成本 vs 标准成本', useRow, stdRow, costColors[2])}
|
||||||
|
{renderRow(useRow, costColors[2], true)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 附:成本情况明细 */}
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: tokens.textSub, marginBottom: 6 }}>附:成本情况</div>
|
||||||
|
<div style={{ overflowX: 'auto', borderRadius: 8, overflow: 'hidden', border: isLight ? '1px solid rgba(30,60,130,0.12)' : `1px solid ${tokens.border}`, boxShadow: isLight ? '0 1px 6px rgba(30,60,130,0.06)' : 'none' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 800 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(22,56,140,0.88)' : 'transparent' }}>
|
||||||
|
{headerCell('项目', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('成本明细', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('标准成本', undefined, 2, isLight ? 'rgba(0,20,80,0.85)' : 'rgba(123,97,255,0.18)')}
|
||||||
|
{headerCell('采购成本', undefined, 2, isLight ? 'rgba(0,20,80,0.85)' : 'rgba(0,212,255,0.14)')}
|
||||||
|
{headerCell('领用成本', undefined, 2, isLight ? 'rgba(0,20,80,0.85)' : 'rgba(0,229,153,0.14)')}
|
||||||
|
{headerCell('采购成本与标准成本差异', undefined, 2, isLight ? 'rgba(0,20,80,0.85)' : 'rgba(255,179,64,0.12)')}
|
||||||
|
{headerCell('领用成本与标准成本差异', undefined, 2, isLight ? 'rgba(0,20,80,0.85)' : 'rgba(255,107,107,0.12)')}
|
||||||
|
</tr>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(30,60,130,0.06)' : 'transparent' }}>
|
||||||
|
{headerCell('')}
|
||||||
|
{headerCell('')}
|
||||||
|
{headerCell('金额')}
|
||||||
|
{headerCell('占销售比')}
|
||||||
|
{headerCell('金额')}
|
||||||
|
{headerCell('占销售比')}
|
||||||
|
{headerCell('金额')}
|
||||||
|
{headerCell('占销售比')}
|
||||||
|
{headerCell('金额')}
|
||||||
|
{headerCell('占销售比')}
|
||||||
|
{headerCell('金额')}
|
||||||
|
{headerCell('占销售比')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{order.costBreakdown.map((group) => {
|
||||||
|
const rows = group.items;
|
||||||
|
const bStyle = `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`;
|
||||||
|
const bStyleHeader = `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`;
|
||||||
|
return rows.map((item, itemIdx) => {
|
||||||
|
const pAmt = item.purchaseVsStdAmt;
|
||||||
|
const uAmt = item.usageVsStdAmt;
|
||||||
|
return (
|
||||||
|
<tr key={`${group.category}-${item.label}`}
|
||||||
|
style={{ background: isLight ? (itemIdx % 2 === 0 ? 'rgba(241,245,255,0.6)' : '#ffffff') : (itemIdx % 2 === 0 ? tokens.tableRowAlt : 'transparent') }}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = isLight ? 'rgba(26,86,219,0.07)' : tokens.tableRowHover; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = isLight ? (itemIdx % 2 === 0 ? 'rgba(241,245,255,0.6)' : '#ffffff') : (itemIdx % 2 === 0 ? tokens.tableRowAlt : 'transparent'); }}
|
||||||
|
>
|
||||||
|
{itemIdx === 0 && (
|
||||||
|
<td rowSpan={rows.length} style={{
|
||||||
|
padding: '6px 10px', fontSize: 12, fontWeight: 700,
|
||||||
|
color: tokens.textSub, textAlign: 'center',
|
||||||
|
border: bStyleHeader,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
background: isLight ? 'rgba(30,60,130,0.05)' : tokens.tableHeaderBg,
|
||||||
|
}}>
|
||||||
|
{group.category}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: tokens.textMuted, border: bStyle, textAlign: 'center' }}>
|
||||||
|
{item.label}
|
||||||
|
</td>
|
||||||
|
{/* 标准成本 */}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[0] : tokens.accent, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap' }}>
|
||||||
|
{fmt(item.stdCost, 2)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[0] : tokens.accent, textAlign: 'right', border: bStyle }}>
|
||||||
|
{item.stdPct > 0 ? `${(item.stdPct * 100).toFixed(2)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
{/* 采购成本 */}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[1] : tokens.textSub, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap' }}>
|
||||||
|
{fmt(item.purchaseCost, 2)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[1] : tokens.textSub, textAlign: 'right', border: bStyle }}>
|
||||||
|
{item.purchasePct > 0 ? `${(item.purchasePct * 100).toFixed(2)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
{/* 领用成本 */}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[2] : tokens.success, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap' }}>
|
||||||
|
{fmt(item.usageCost, 2)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[2] : tokens.success, textAlign: 'right', border: bStyle }}>
|
||||||
|
{item.usagePct > 0 ? `${(item.usagePct * 100).toFixed(2)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
{/* 采购 vs 标准差异 */}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: colorPct(item.purchaseVsStdPct, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: Math.abs(pAmt) > 2000 ? 700 : 400 }}>
|
||||||
|
{pAmt !== 0 ? `${pAmt >= 0 ? '+' : ''}${fmt(pAmt, 2)}` : '0'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: colorPct(item.purchaseVsStdPct, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, fontWeight: Math.abs(item.purchaseVsStdPct) > 0.02 ? 700 : 400 }}>
|
||||||
|
{item.purchaseVsStdPct !== 0 ? pct(item.purchaseVsStdPct) : '0.00%'}
|
||||||
|
</td>
|
||||||
|
{/* 领用 vs 标准差异 */}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: colorPct(item.usageVsStdPct, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: Math.abs(uAmt) > 2000 ? 700 : 400 }}>
|
||||||
|
{uAmt !== 0 ? `${uAmt >= 0 ? '+' : ''}${fmt(uAmt, 2)}` : '0'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: colorPct(item.usageVsStdPct, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, fontWeight: Math.abs(item.usageVsStdPct) > 0.02 ? 700 : 400 }}>
|
||||||
|
{item.usageVsStdPct !== 0 ? pct(item.usageVsStdPct) : '0.00%'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
{/* 合计行 */}
|
||||||
|
{(() => {
|
||||||
|
const sumStd = order.costBreakdown.reduce((s, g) => s + g.items.reduce((ss, i) => ss + i.stdCost, 0), 0);
|
||||||
|
const sumPur = order.costBreakdown.reduce((s, g) => s + g.items.reduce((ss, i) => ss + i.purchaseCost, 0), 0);
|
||||||
|
const sumUse = order.costBreakdown.reduce((s, g) => s + g.items.reduce((ss, i) => ss + i.usageCost, 0), 0);
|
||||||
|
const pVsS = sumPur - sumStd;
|
||||||
|
const uVsS = sumUse - sumStd;
|
||||||
|
const rev = stdRow.revenue;
|
||||||
|
const bStyle = `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`;
|
||||||
|
const bStyleH = `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`;
|
||||||
|
return (
|
||||||
|
<tr style={{ background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, fontWeight: 700 }}>
|
||||||
|
<td colSpan={2} style={{ padding: '7px 10px', fontSize: 12, color: tokens.textSub, textAlign: 'center', border: bStyleH, fontWeight: 700 }}>合计</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[0] : tokens.accent, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: 700 }}>{fmt(sumStd, 2)}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[0] : tokens.accent, textAlign: 'right', border: bStyle, fontWeight: 700 }}>{rev > 0 ? `${(sumStd / rev * 100).toFixed(2)}%` : '—'}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[1] : tokens.textSub, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: 700 }}>{fmt(sumPur, 2)}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[1] : tokens.textSub, textAlign: 'right', border: bStyle, fontWeight: 700 }}>{rev > 0 ? `${(sumPur / rev * 100).toFixed(2)}%` : '—'}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[2] : tokens.success, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: 700 }}>{fmt(sumUse, 2)}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[2] : tokens.success, textAlign: 'right', border: bStyle, fontWeight: 700 }}>{rev > 0 ? `${(sumUse / rev * 100).toFixed(2)}%` : '—'}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: colorPct(pVsS / sumStd, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: 700 }}>{`${pVsS >= 0 ? '+' : ''}${fmt(pVsS, 2)}`}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: colorPct(pVsS / sumStd, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, fontWeight: 700 }}>{pct(pVsS / sumStd)}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: colorPct(uVsS / sumStd, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: 700 }}>{`${uVsS >= 0 ? '+' : ''}${fmt(uVsS, 2)}`}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: colorPct(uVsS / sumStd, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, fontWeight: 700 }}>{pct(uVsS / sumStd)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 报价成本对比(如果有数据) */}
|
||||||
|
{order.quoteCostItems && order.quoteCostItems.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: tokens.textSub, marginBottom: 6 }}>二、与客户报价成本细项对比</div>
|
||||||
|
<div style={{ overflowX: 'auto', borderRadius: 8, overflow: 'hidden', border: isLight ? '1px solid rgba(30,60,130,0.12)' : `1px solid ${tokens.border}`, boxShadow: isLight ? '0 1px 6px rgba(30,60,130,0.06)' : 'none' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 600 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(22,56,140,0.88)' : 'transparent' }}>
|
||||||
|
{headerCell('成本细项', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('报价单价', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('数量', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('报价总成本', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('占报价比', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('标准成本', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('采购成本', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('领用成本', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('报价 vs 领用差异', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{order.quoteCostItems.map((qi, i) => {
|
||||||
|
const stdItem = order.costBreakdown.flatMap(g => g.items).find(it => it.label.includes(qi.name.slice(0, 2)));
|
||||||
|
const useAmt = stdItem?.usageCost ?? 0;
|
||||||
|
const diff = qi.totalCost - useAmt;
|
||||||
|
const quoteColor = isLight ? costColors[0] : '#ffb340';
|
||||||
|
return (
|
||||||
|
<tr key={qi.name} style={{ background: isLight ? (i % 2 === 0 ? 'rgba(241,245,255,0.6)' : '#ffffff') : (i % 2 === 0 ? tokens.tableRowAlt : 'transparent') }}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = isLight ? 'rgba(26,86,219,0.07)' : tokens.tableRowHover; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = isLight ? (i % 2 === 0 ? 'rgba(241,245,255,0.6)' : '#ffffff') : (i % 2 === 0 ? tokens.tableRowAlt : 'transparent'); }}
|
||||||
|
>
|
||||||
|
{dataCell(qi.name, quoteColor, 'center', true, true)}
|
||||||
|
{dataCell(fmt(qi.quotedPrice, 2), undefined, 'right')}
|
||||||
|
{dataCell(fmt(qi.quantity), undefined, 'right')}
|
||||||
|
{dataCell(fmt(qi.totalCost, 2), quoteColor, 'right', true)}
|
||||||
|
{dataCell(`${(qi.percentage * 100).toFixed(2)}%`, quoteColor, 'right')}
|
||||||
|
{dataCell(stdItem ? fmt(stdItem.stdCost, 2) : '—', isLight ? costColors[0] : tokens.accent, 'right')}
|
||||||
|
{dataCell(stdItem ? fmt(stdItem.purchaseCost, 2) : '—', isLight ? costColors[1] : tokens.textSub, 'right')}
|
||||||
|
{dataCell(stdItem ? fmt(stdItem.usageCost, 2) : '—', isLight ? costColors[2] : tokens.success, 'right')}
|
||||||
|
{dataCell(useAmt > 0 ? `${diff >= 0 ? '+' : ''}${fmt(diff, 2)}` : '—',
|
||||||
|
useAmt > 0 ? (diff >= 0 ? tokens.success : tokens.danger) : undefined, 'right', true)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 备注 */}
|
||||||
|
{order.note && (
|
||||||
|
<div style={{ padding: '10px 14px', borderRadius: 10, background: tokens.warningBg, border: `1px solid ${tokens.warning}33`, marginBottom: 8 }}>
|
||||||
|
<div style={{ color: tokens.warning, fontSize: 12, fontWeight: 700, marginBottom: 4 }}>AI 分析备注</div>
|
||||||
|
<div style={{ color: tokens.text, fontSize: 12, lineHeight: 1.7 }}>{order.note}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 思考计算动画 ──────────────────────────────────────────────────────────────────
|
||||||
|
function ProcessingAnimation({ order }: { order: Order }) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const costColors = isLight
|
||||||
|
? ['#1e40af', '#2563eb', '#3b82f6']
|
||||||
|
: ['#7b61ff', '#00d4ff', '#00e599'];
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 28, padding: 40,
|
||||||
|
}}>
|
||||||
|
{/* 旋转卡片 */}
|
||||||
|
<div style={{ perspective: '1000px', width: 260, height: 160 }}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
borderRadius: 16,
|
||||||
|
border: `1px solid ${isLight ? 'rgba(30,60,130,0.15)' : tokens.borderAccent}`,
|
||||||
|
background: isLight
|
||||||
|
? 'linear-gradient(145deg, #ffffff 0%, rgba(235,242,255,0.95) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(0,30,80,0.8) 0%, rgba(0,15,50,0.9) 100%)',
|
||||||
|
boxShadow: isLight
|
||||||
|
? '0 8px 32px rgba(30,60,130,0.12), 0 2px 8px rgba(30,60,130,0.07), inset 0 1px 0 rgba(255,255,255,1)'
|
||||||
|
: `0 0 40px ${tokens.accentGlow}33, 0 0 80px rgba(0,100,200,0.1), inset 0 0 20px ${tokens.accentBg}`,
|
||||||
|
animation: 'cardSpin 2.5s ease-in-out infinite',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 10, padding: 16,
|
||||||
|
transformStyle: 'preserve-3d',
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* 浅色卡片装饰线条 */}
|
||||||
|
{isLight && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: 3,
|
||||||
|
background: `linear-gradient(to right, #1e40af, #3b82f6, #1e40af)`,
|
||||||
|
borderRadius: '16px 16px 0 0',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
<div style={{ color: costColors[0], fontSize: 13, fontWeight: 700, letterSpacing: '0.04em', fontFamily: 'monospace', marginTop: isLight ? 4 : 0 }}>
|
||||||
|
{order.orderNo}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.text, fontSize: 16, fontWeight: 800 }}>
|
||||||
|
{order.customer}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 12 }}>
|
||||||
|
{order.productCategory}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||||
|
{costColors.map((c, i) => (
|
||||||
|
<span key={i} style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: c, opacity: isLight ? 0.75 : 0.8,
|
||||||
|
animation: `dotPulse 1.2s ${i * 0.3}s ease-in-out infinite`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 思考文字 */}
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
color: tokens.textSub, fontSize: 16, fontWeight: 700,
|
||||||
|
marginBottom: 10, letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
AI 正在分析订单成本...
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
|
color: tokens.textMuted, fontSize: 13,
|
||||||
|
}}>
|
||||||
|
<span style={{ animation: 'processDot 1.4s infinite' }}>•</span>
|
||||||
|
<span>计算标准成本、采购成本、领用成本偏差</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||||
|
}}>
|
||||||
|
{[0, 1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} style={{
|
||||||
|
width: 4, height: 16, borderRadius: 2,
|
||||||
|
background: costColors[Math.min(i, costColors.length - 1)],
|
||||||
|
opacity: isLight ? 0.5 + i * 0.08 : 1,
|
||||||
|
animation: `barWave 0.8s ${i * 0.12}s ease-in-out infinite alternate`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSS 动画 */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes cardSpin {
|
||||||
|
0% { transform: rotateY(0deg) rotateX(2deg); }
|
||||||
|
25% { transform: rotateY(15deg) rotateX(-2deg); }
|
||||||
|
50% { transform: rotateY(0deg) rotateX(2deg); }
|
||||||
|
75% { transform: rotateY(-15deg) rotateX(-2deg); }
|
||||||
|
100% { transform: rotateY(0deg) rotateX(2deg); }
|
||||||
|
}
|
||||||
|
@keyframes dotPulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.5; }
|
||||||
|
50% { transform: scale(1.6); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes processDot {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes barWave {
|
||||||
|
0% { height: 6px; opacity: 0.3; }
|
||||||
|
100% { height: 22px; opacity: 0.8; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 导出组件 ──────────────────────────────────────────────────────────────────
|
||||||
|
export default function CenterProfitTable({ order, onDrop, isProcessing, processingOrder }: CenterProfitTableProps) {
|
||||||
|
if (isProcessing && processingOrder) return <ProcessingAnimation order={processingOrder} />;
|
||||||
|
if (!order) return <EmptyState onDrop={onDrop} />;
|
||||||
|
return <ProfitTable order={order} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ORDERS } from '../data/orders';
|
||||||
|
import { useTheme } from '@/shared/context/ThemeContext';
|
||||||
|
import ThemeToggle from '@/shared/components/ThemeToggle';
|
||||||
|
|
||||||
|
export default function CostHeader() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { tokens } = useTheme();
|
||||||
|
const [time, setTime] = useState(new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setTime(new Date()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const timeStr = `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`;
|
||||||
|
const dateStr = `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())}`;
|
||||||
|
|
||||||
|
const totalOrders = ORDERS.length;
|
||||||
|
const closedOrders = ORDERS.filter(o => o.status === 'closed').length;
|
||||||
|
const dangerOrders = ORDERS.filter(o => o.riskLevel === 'danger').length;
|
||||||
|
const warningOrders = ORDERS.filter(o => o.riskLevel === 'warning').length;
|
||||||
|
|
||||||
|
// 计算所有已结单订单的平均净利率(领用成本口径)
|
||||||
|
const closedList = ORDERS.filter(o => o.status === 'closed');
|
||||||
|
const avgNetMargin = closedList.length > 0
|
||||||
|
? closedList.reduce((sum, o) => sum + (o.rows.find(r => r.type === '领用成本')?.netMargin ?? 0), 0) / closedList.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: '在管订单', value: String(totalOrders), color: tokens.accent },
|
||||||
|
{ label: '已结单', value: String(closedOrders), color: tokens.success },
|
||||||
|
{ label: '成本预警', value: String(warningOrders), color: tokens.warning },
|
||||||
|
{ label: '亏损风险', value: String(dangerOrders), color: tokens.danger },
|
||||||
|
{ label: '平均净利率', value: `${(avgNetMargin * 100).toFixed(1)}%`, color: avgNetMargin >= 0.05 ? tokens.success : avgNetMargin >= 0 ? tokens.warning : tokens.danger },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header style={{
|
||||||
|
height: 88, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
padding: '0 24px',
|
||||||
|
background: 'transparent',
|
||||||
|
position: 'relative', zIndex: 10, overflow: 'visible',
|
||||||
|
}}>
|
||||||
|
{/* 顶部扫光线 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: 1,
|
||||||
|
background: tokens.headerScanLine,
|
||||||
|
boxShadow: `0 0 12px ${tokens.accentGlow}`, zIndex: 2,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 底部弧形 */}
|
||||||
|
<svg style={{
|
||||||
|
position: 'absolute', bottom: -26, left: 0,
|
||||||
|
width: '100%', height: 28,
|
||||||
|
pointerEvents: 'none', zIndex: 0, overflow: 'visible',
|
||||||
|
}} viewBox="0 0 1000 28" preserveAspectRatio="none">
|
||||||
|
<path d="M 0,0 C 250,28 750,28 1000,0" fill="none" stroke={tokens.svgStroke} strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<path d="M 0,0 C 250,28 750,28 1000,0" fill="none" stroke={tokens.svgDot.replace('0.9', '0.1')} strokeWidth="6" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="0" cy="0" r="2.5" fill={tokens.svgDot.replace('0.9', '0.6')} />
|
||||||
|
<circle cx="1000" cy="0" r="2.5" fill={tokens.svgDot.replace('0.9', '0.6')} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Logo + 标题 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flex: 1, position: 'relative', zIndex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
height: 46, borderRadius: 8,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<img src="/xinmo-logo.png" alt="欣墨科技" style={{ height: '100%', objectFit: 'contain' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<h1 style={{
|
||||||
|
color: tokens.text, fontSize: 19, fontWeight: 700,
|
||||||
|
letterSpacing: '0.05em', margin: 0,
|
||||||
|
textShadow: tokens.titleGlow,
|
||||||
|
}}>
|
||||||
|
AI 订单成本分析系统
|
||||||
|
</h1>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '2px 8px', borderRadius: 10,
|
||||||
|
background: tokens.accentBg, border: `1px solid ${tokens.accent}55`,
|
||||||
|
fontSize: 12, color: tokens.accent, fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 5, height: 5, borderRadius: '50%',
|
||||||
|
backgroundColor: tokens.success,
|
||||||
|
boxShadow: `0 0 8px ${tokens.success}`,
|
||||||
|
animation: 'space-pulse 2s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 12, margin: 0, letterSpacing: '0.04em', lineHeight: 1.45 }}>
|
||||||
|
<div>出货收入 · 标准成本 · 采购成本 · 领用成本 · 毛利分析</div>
|
||||||
|
<div>单位:元 币种:人民币</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<div style={{ width: 1, height: 44, background: `linear-gradient(to bottom, transparent, ${tokens.accent}33, transparent)`, flexShrink: 0, marginRight: 24, position: 'relative', zIndex: 1 }} />
|
||||||
|
|
||||||
|
{/* 统计指标 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'center', position: 'relative', zIndex: 1, marginTop: 24 }}>
|
||||||
|
{stats.map((item, idx) => (
|
||||||
|
<div key={item.label} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center', padding: '6px 16px', borderRadius: 10,
|
||||||
|
background: tokens.kpiCardBg, border: `1px solid ${tokens.kpiCardBorder}`,
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: item.color, fontSize: 24, fontWeight: 800, lineHeight: 1, textShadow: `0 0 10px ${item.color}80` }}>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 13, marginTop: 3, letterSpacing: '0.04em', fontWeight: 600 }}>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{idx < stats.length - 1 && <div style={{ width: 1, height: 36, background: `${tokens.accent}18`, margin: '0 3px' }} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右分隔线 */}
|
||||||
|
<div style={{ width: 1, height: 44, background: `linear-gradient(to bottom, transparent, ${tokens.accent}33, transparent)`, flexShrink: 0, marginLeft: 24, position: 'relative', zIndex: 1 }} />
|
||||||
|
|
||||||
|
{/* 时间 + 操作按钮 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, justifyContent: 'flex-end', position: 'relative', zIndex: 1 }}>
|
||||||
|
<ThemeToggle />
|
||||||
|
<button onClick={() => navigate('/')} style={{
|
||||||
|
padding: '4px 12px', borderRadius: 8, cursor: 'pointer',
|
||||||
|
border: `1px solid ${tokens.accent}55`,
|
||||||
|
background: tokens.accentBg,
|
||||||
|
color: tokens.accent, fontSize: 12, fontWeight: 600,
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = `${tokens.accent}18`; e.currentTarget.style.borderColor = tokens.accent; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = tokens.accentBg; e.currentTarget.style.borderColor = `${tokens.accent}55`; }}
|
||||||
|
>
|
||||||
|
← 返回平台
|
||||||
|
</button>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ color: tokens.accent, fontSize: 20, fontWeight: 700, fontVariantNumeric: 'tabular-nums', lineHeight: 1, textShadow: `0 0 16px ${tokens.accentGlow}`, letterSpacing: '0.05em' }}>
|
||||||
|
{timeStr}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 11, marginTop: 2, letterSpacing: '0.04em' }}>
|
||||||
|
{dateStr}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: `${tokens.accent}88`, fontSize: 11, marginTop: 4, letterSpacing: '0.06em' }}>
|
||||||
|
Powered by EIOS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ORDERS, statusConfig, riskConfig } from '../data/orders';
|
||||||
|
import { useTheme } from '@/shared/context/ThemeContext';
|
||||||
|
|
||||||
|
interface LeftOrderPanelProps {
|
||||||
|
selectedId: string | null;
|
||||||
|
onExpandChange?: (expanded: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterStatus = 'all' | 'closed' | 'production' | 'shipped';
|
||||||
|
type FilterRisk = 'all' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
export default function LeftOrderPanel({ selectedId, onExpandChange }: LeftOrderPanelProps) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
|
||||||
|
// 浅色模式下标签颜色统一收敛:状态用accent蓝色,风险保留功能色,渠道用灰色
|
||||||
|
const getLabelColor = (rawColor: string, type: 'status' | 'risk' | 'channel') => {
|
||||||
|
if (!isLight) return rawColor;
|
||||||
|
if (type === 'risk') return rawColor; // 风险保留原色(success/warning/danger)
|
||||||
|
return tokens.accent; // status和channel统一用蓝色
|
||||||
|
};
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<FilterStatus>('all');
|
||||||
|
const [riskFilter, setRiskFilter] = useState<FilterRisk>('all');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const handleExpand = () => {
|
||||||
|
const next = !expanded;
|
||||||
|
setExpanded(next);
|
||||||
|
onExpandChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
let filtered = ORDERS;
|
||||||
|
if (statusFilter !== 'all') filtered = filtered.filter(o => o.status === statusFilter);
|
||||||
|
if (riskFilter !== 'all') filtered = filtered.filter(o => o.riskLevel === riskFilter);
|
||||||
|
|
||||||
|
const dangerCount = ORDERS.filter(o => o.riskLevel === 'danger').length;
|
||||||
|
const warningCount = ORDERS.filter(o => o.riskLevel === 'warning').length;
|
||||||
|
|
||||||
|
const W = expanded ? 460 : 268;
|
||||||
|
const BULGE = 28;
|
||||||
|
const totalW = W + BULGE;
|
||||||
|
const outlinePath = `M 0,0 L ${W - 70},0 Q ${W},0 ${W},80 C ${W + BULGE},2000 ${W + BULGE},8000 ${W},9920 L 0,10000 Z`;
|
||||||
|
const strokePath = `M 0,0.5 L ${W - 70},0.5 Q ${W - 0.5},0.5 ${W - 0.5},80 C ${W - 0.5 + BULGE},2000 ${W - 0.5 + BULGE},8000 ${W - 0.5},9920`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-left" style={{
|
||||||
|
width: totalW, height: '100%', flexShrink: 0,
|
||||||
|
position: 'relative', overflow: 'visible',
|
||||||
|
background: 'transparent',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
<svg style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, overflow: 'visible' }}
|
||||||
|
viewBox={`0 0 ${totalW} 10000`} preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="leftOrderBg" x1="1" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={tokens.svgPanelFrom} />
|
||||||
|
<stop offset="100%" stopColor={tokens.svgPanelTo} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={outlinePath} fill="url(#leftOrderBg)" />
|
||||||
|
<path d={strokePath} fill="none" stroke={tokens.svgStroke} strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<line x1="0.5" y1="0" x2="0.5" y2="10000" stroke={tokens.svgStroke.replace('0.45', '0.15')} strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W - 70} cy="1" r="3" fill={tokens.svgDot} vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W} cy="80" r="2.5" fill={tokens.svgStroke} vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W + BULGE} cy="5000" r="2" fill={tokens.svgStroke.replace('0.45', '0.25')} vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1, width: W,
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
background: 'transparent',
|
||||||
|
}}>
|
||||||
|
{/* 标题区 */}
|
||||||
|
<div style={{ padding: '14px 12px 10px 14px', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<span style={{ width: 3, height: 16, borderRadius: 2, background: `linear-gradient(to bottom,${tokens.accent},${tokens.accent}88)`, display: 'inline-block' }} />
|
||||||
|
<span style={{ color: tokens.textSub, fontSize: 19, fontWeight: 700, letterSpacing: '0.04em' }}>订单列表</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
{dangerCount > 0 && (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 8, background: `${tokens.danger}22`, color: tokens.danger, fontSize: 13, fontWeight: 700 }}>
|
||||||
|
{dangerCount}亏损
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{warningCount > 0 && (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 8, background: `${tokens.warning}22`, color: tokens.warning, fontSize: 13, fontWeight: 700 }}>
|
||||||
|
{warningCount}预警
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={handleExpand} style={{
|
||||||
|
padding: '3px 8px', borderRadius: 7,
|
||||||
|
border: `1px solid ${expanded ? tokens.accent + '60' : tokens.border}`,
|
||||||
|
background: expanded ? tokens.accentBg : 'transparent',
|
||||||
|
color: expanded ? tokens.accent : tokens.textMuted,
|
||||||
|
fontSize: 12, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{expanded ? '收起' : '展开'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态筛选 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 10 }}>
|
||||||
|
{(['all', 'closed', 'production', 'shipped'] as FilterStatus[]).map(s => {
|
||||||
|
const isActive = statusFilter === s;
|
||||||
|
const cfg = s !== 'all' ? statusConfig[s] : null;
|
||||||
|
return (
|
||||||
|
<button key={s} onClick={() => setStatusFilter(s)} style={{
|
||||||
|
flex: 1, padding: '5px 0', borderRadius: 5, fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1px solid ${isActive ? tokens.accent + '60' : tokens.borderSub}`,
|
||||||
|
background: isActive ? tokens.accentBg : 'transparent',
|
||||||
|
color: isActive ? tokens.accent : tokens.textMuted,
|
||||||
|
}}>
|
||||||
|
{s === 'all' ? '全部' : cfg!.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 风险筛选 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 5 }}>
|
||||||
|
{(['all', 'warning', 'danger'] as FilterRisk[]).map(r => {
|
||||||
|
const isActive = riskFilter === r;
|
||||||
|
const cfg = r !== 'all' ? riskConfig[r] : null;
|
||||||
|
return (
|
||||||
|
<button key={r} onClick={() => setRiskFilter(r)} style={{
|
||||||
|
flex: 1, padding: '4px 0', borderRadius: 5, fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1px solid ${isActive ? (cfg?.color + '60' || tokens.accent + '60') : tokens.borderSub}`,
|
||||||
|
background: isActive ? (cfg?.color ? cfg.color + '10' : tokens.accentBg) : 'transparent',
|
||||||
|
color: isActive ? (cfg ? getLabelColor(cfg.color, 'risk') : tokens.accent) : tokens.textMuted,
|
||||||
|
}}>
|
||||||
|
{r === 'all' ? '风险:全部' : cfg!.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: tokens.border, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 订单列表 */}
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 8px 14px' }}>
|
||||||
|
{filtered.map((order, idx) => {
|
||||||
|
const isSelected = selectedId === order.id;
|
||||||
|
const sCfg = statusConfig[order.status];
|
||||||
|
const rCfg = riskConfig[order.riskLevel];
|
||||||
|
const usageRow = order.rows.find(r => r.type === '领用成本');
|
||||||
|
const netMargin = usageRow?.netMargin ?? 0;
|
||||||
|
|
||||||
|
const isDragging = draggingId === order.id;
|
||||||
|
return (
|
||||||
|
<div key={order.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={e => {
|
||||||
|
e.dataTransfer.setData('application/x-order-id', order.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
setDraggingId(order.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setDraggingId(null)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 11px', borderRadius: 10, marginBottom: 7,
|
||||||
|
border: `1px solid ${isSelected ? tokens.accent + '55' : isDragging ? tokens.accent + '99' : (order.riskLevel === 'danger' ? tokens.danger + '40' : tokens.border)}`, background: isSelected
|
||||||
|
? `linear-gradient(135deg, ${tokens.accent}18 0%, ${tokens.accent}08 100%)`
|
||||||
|
: isDragging ? `${tokens.accent}18`
|
||||||
|
: order.riskLevel === 'danger' ? tokens.dangerBg : tokens.panelBg,
|
||||||
|
cursor: 'grab', transition: 'all 0.18s ease',
|
||||||
|
animation: `notice-in 0.3s ${idx * 0.05}s both`,
|
||||||
|
boxShadow: isSelected ? `0 0 14px ${tokens.accent}25` : isDragging ? `0 0 18px ${tokens.accent}45` : 'none',
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
transform: isDragging ? 'scale(0.97)' : 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isSelected && !isDragging) e.currentTarget.style.background = tokens.panelBgAlt; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected && !isDragging) e.currentTarget.style.background = order.riskLevel === 'danger' ? tokens.dangerBg : tokens.panelBg; }}
|
||||||
|
>
|
||||||
|
{/* 第一行:订单号 + 状态 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: isSelected ? tokens.accent : tokens.textSub, fontSize: 13, fontFamily: 'monospace', letterSpacing: '0.02em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||||
|
{order.orderNo}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 6px', borderRadius: 4, flexShrink: 0,
|
||||||
|
background: isLight ? tokens.accentBg : `${sCfg.color}18`,
|
||||||
|
border: `1px solid ${isLight ? tokens.borderAccent : sCfg.color + '40'}`,
|
||||||
|
color: isLight ? tokens.accent : sCfg.color, fontSize: 12, fontWeight: 600,
|
||||||
|
}}>{sCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第二行:客户 + 风险 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 4 }}>
|
||||||
|
<span style={{ color: tokens.text, fontSize: 15, fontWeight: 700 }}>{order.customer}</span>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 6px', borderRadius: 4,
|
||||||
|
background: isLight ? (order.riskLevel === 'danger' ? tokens.dangerBg : order.riskLevel === 'warning' ? tokens.warningBg : tokens.successBg) : rCfg.bg,
|
||||||
|
border: `1px solid ${isLight ? (order.riskLevel === 'danger' ? tokens.danger + '40' : order.riskLevel === 'warning' ? tokens.warning + '40' : tokens.success + '40') : rCfg.color + '40'}`,
|
||||||
|
color: isLight ? (order.riskLevel === 'danger' ? tokens.danger : order.riskLevel === 'warning' ? tokens.warning : tokens.success) : rCfg.color,
|
||||||
|
fontSize: 12, fontWeight: 700,
|
||||||
|
}}>{rCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第三行:品类 + 渠道 */}
|
||||||
|
<div style={{ display: 'flex', gap: 5, alignItems: 'center', marginTop: 4, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ color: tokens.textMuted, fontSize: 13 }}>{order.productCategory}</span>
|
||||||
|
<span style={{ padding: '2px 6px', borderRadius: 4, background: tokens.accentBg, border: `1px solid ${tokens.borderSub}`, color: tokens.textMuted, fontSize: 12, fontWeight: 600 }}>
|
||||||
|
{order.channel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 毛利/净利率指示条 */}
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: tokens.textMuted, fontSize: 12, flexShrink: 0 }}>净利率</span>
|
||||||
|
<div style={{ flex: 1, height: 3, borderRadius: 2, background: tokens.border, overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
width: `${Math.min(100, Math.max(0, Math.abs(netMargin) * 200))}%`,
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 2,
|
||||||
|
background: netMargin >= 0.08 ? `linear-gradient(to right,${tokens.success}88,${tokens.success})` : netMargin >= 0 ? `linear-gradient(to right,${tokens.warning}88,${tokens.warning})` : `linear-gradient(to right,${tokens.danger}88,${tokens.danger})`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
color: netMargin >= 0.05 ? tokens.success : netMargin >= 0 ? tokens.warning : tokens.danger,
|
||||||
|
fontSize: 13, fontWeight: 700, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{(netMargin * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', gap: 4, flexWrap: 'wrap', animation: 'notice-in 0.2s both' }}>
|
||||||
|
{order.tags.map(tag => (
|
||||||
|
<span key={tag} style={{ padding: '1px 6px', borderRadius: 4, background: tokens.accentBg, border: `1px solid ${tokens.border}`, color: tokens.textSub, fontSize: 10 }}>#{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ORDERS, riskConfig, statusConfig, type Order } from '../data/orders';
|
||||||
|
import { useTheme } from '@/shared/context/ThemeContext';
|
||||||
|
|
||||||
|
interface RightAIPanelProps {
|
||||||
|
selectedOrder: Order | null;
|
||||||
|
onExpandChange?: (expanded: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RightAIPanel({ selectedOrder, onExpandChange }: RightAIPanelProps) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'insight' | 'rank' | 'trend'>('insight');
|
||||||
|
|
||||||
|
const handleExpand = () => {
|
||||||
|
const next = !expanded;
|
||||||
|
setExpanded(next);
|
||||||
|
onExpandChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const W = expanded ? 480 : 268;
|
||||||
|
const BULGE = 28;
|
||||||
|
|
||||||
|
const outlinePath = `M ${W},0 L 70,0 Q 0,0 0,80 C ${-BULGE},2000 ${-BULGE},8000 0,9920 L ${W},10000 Z`;
|
||||||
|
const strokePath = `M ${W},0.5 L 70,0.5 Q 0.5,0.5 0.5,80 C ${-BULGE + 0.5},2000 ${-BULGE + 0.5},8000 0.5,9920`;
|
||||||
|
|
||||||
|
// 计算所有订单的排名数据(领用成本口径净利率)
|
||||||
|
const ranked = [...ORDERS]
|
||||||
|
.map(o => ({ order: o, netMargin: o.rows.find(r => r.type === '领用成本')?.netMargin ?? 0 }))
|
||||||
|
.sort((a, b) => b.netMargin - a.netMargin);
|
||||||
|
|
||||||
|
// 当前订单的AI洞察
|
||||||
|
const insightItems = selectedOrder ? generateInsights(selectedOrder, { danger: tokens.danger, warning: tokens.warning, success: tokens.success, accent: tokens.accent, textSub: tokens.textSub }) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-right" style={{
|
||||||
|
width: W + BULGE, height: '100%', flexShrink: 0,
|
||||||
|
position: 'relative', overflow: 'visible', background: 'transparent',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
<svg style={{
|
||||||
|
position: 'absolute', top: 0, left: -BULGE,
|
||||||
|
width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, overflow: 'visible',
|
||||||
|
}} viewBox={`${-BULGE} 0 ${W + BULGE} 10000`} preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="rightAIBg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor={tokens.svgPanelFrom} />
|
||||||
|
<stop offset="100%" stopColor={tokens.svgPanelTo} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={outlinePath} fill="url(#rightAIBg)" />
|
||||||
|
<path d={strokePath} fill="none" stroke={tokens.svgStroke} strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<line x1={W - 0.5} y1="0" x2={W - 0.5} y2="10000" stroke={tokens.svgStroke.replace('0.45','0.15')} strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="70" cy="1" r="3" fill={tokens.svgDot} vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="0" cy="80" r="2.5" fill={tokens.svgStroke} vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={-BULGE} cy="5000" r="2" fill={tokens.svgStroke.replace('0.45','0.25')} vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1, marginLeft: BULGE, width: W,
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
background: 'transparent',
|
||||||
|
}}>
|
||||||
|
{/* 标题 */}
|
||||||
|
<div style={{ padding: '14px 14px 10px 12px', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<span style={{ width: 3, height: 16, borderRadius: 2, background: `linear-gradient(to bottom,${tokens.success},${tokens.success}88)`, display: 'inline-block' }} />
|
||||||
|
<span style={{ color: tokens.success, fontSize: 19, fontWeight: 700, letterSpacing: '0.04em' }}>AI 分析视图</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleExpand} style={{
|
||||||
|
padding: '2px 8px', borderRadius: 7,
|
||||||
|
border: `1px solid ${expanded ? tokens.success + '60' : tokens.border}`,
|
||||||
|
background: expanded ? tokens.successBg : 'transparent',
|
||||||
|
color: expanded ? tokens.success : tokens.textMuted,
|
||||||
|
fontSize: 12, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{expanded ? '收起' : '展开'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 10 }}>
|
||||||
|
{([
|
||||||
|
{ key: 'insight', label: 'AI洞察', icon: (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" style={{flexShrink:0}}>
|
||||||
|
<circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" strokeWidth="1.2"/>
|
||||||
|
<path d="M4.5 5.5 C4.5 3.8 8.5 3.8 8.5 5.5 C8.5 6.8 6.5 7.2 6.5 8.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
<circle cx="6.5" cy="10" r="0.8" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
)},
|
||||||
|
{ key: 'rank', label: '利润排名', icon: (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" style={{flexShrink:0}}>
|
||||||
|
<rect x="1" y="7" width="3" height="5" rx="0.8" fill="currentColor" opacity="0.6"/>
|
||||||
|
<rect x="5" y="4" width="3" height="8" rx="0.8" fill="currentColor" opacity="0.8"/>
|
||||||
|
<rect x="9" y="1" width="3" height="11" rx="0.8" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
)},
|
||||||
|
{ key: 'trend', label: '偏差分析', icon: (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" style={{flexShrink:0}}>
|
||||||
|
<path d="M1 9 L4 6 L6.5 8 L12 2" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M9 2 L12 2 L12 5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)},
|
||||||
|
] as const).map(t => (
|
||||||
|
<button key={t.key} onClick={() => setActiveTab(t.key)} style={{
|
||||||
|
flex: 1, padding: '6px 0', borderRadius: 5, fontSize: 14, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1px solid ${activeTab === t.key ? tokens.success + '80' : tokens.border}`,
|
||||||
|
background: activeTab === t.key ? tokens.successBg : 'transparent',
|
||||||
|
color: activeTab === t.key ? tokens.success : tokens.textMuted,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||||
|
}}>
|
||||||
|
{t.icon}{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: tokens.border, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 内容区 */}
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 8px 12px' }}>
|
||||||
|
|
||||||
|
{/* AI洞察 Tab */}
|
||||||
|
{activeTab === 'insight' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{!selectedOrder ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '30px 0', color: tokens.textMuted, fontSize: 15 }}>
|
||||||
|
点击左侧订单<br />查看AI洞察分析
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 订单概要 */}
|
||||||
|
<div style={{ padding: '10px 12px', borderRadius: 10, background: tokens.successBg, border: `1px solid ${tokens.success}33` }}>
|
||||||
|
<div style={{ color: tokens.success, fontSize: 14, fontWeight: 700, marginBottom: 6 }}>当前订单</div>
|
||||||
|
<div style={{ color: tokens.text, fontSize: 16, fontWeight: 700 }}>{selectedOrder.customer}</div>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 13, fontFamily: 'monospace', marginTop: 2 }}>{selectedOrder.orderNo}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 5, marginTop: 6, flexWrap: 'wrap' }}>
|
||||||
|
{/* 状态标签:浅色统一蓝色 */}
|
||||||
|
<span style={{ padding: '2px 7px', borderRadius: 4,
|
||||||
|
background: isLight ? tokens.accentBg : `${statusConfig[selectedOrder.status].color}18`,
|
||||||
|
border: `1px solid ${isLight ? tokens.borderAccent : statusConfig[selectedOrder.status].color + '40'}`,
|
||||||
|
color: isLight ? tokens.accent : statusConfig[selectedOrder.status].color,
|
||||||
|
fontSize: 12, fontWeight: 600 }}>{statusConfig[selectedOrder.status].label}</span>
|
||||||
|
{/* 风险标签:保留功能色 */}
|
||||||
|
<span style={{ padding: '2px 7px', borderRadius: 4,
|
||||||
|
background: isLight ? (selectedOrder.riskLevel === 'danger' ? tokens.dangerBg : selectedOrder.riskLevel === 'warning' ? tokens.warningBg : tokens.successBg) : riskConfig[selectedOrder.riskLevel].bg,
|
||||||
|
border: `1px solid ${isLight ? (selectedOrder.riskLevel === 'danger' ? tokens.danger + '40' : selectedOrder.riskLevel === 'warning' ? tokens.warning + '40' : tokens.success + '40') : riskConfig[selectedOrder.riskLevel].color + '40'}`,
|
||||||
|
color: isLight ? (selectedOrder.riskLevel === 'danger' ? tokens.danger : selectedOrder.riskLevel === 'warning' ? tokens.warning : tokens.success) : riskConfig[selectedOrder.riskLevel].color,
|
||||||
|
fontSize: 12, fontWeight: 600 }}>{riskConfig[selectedOrder.riskLevel].label}</span>
|
||||||
|
{/* 渠道标签:浅色统一灰色 */}
|
||||||
|
<span style={{ padding: '2px 7px', borderRadius: 4,
|
||||||
|
background: tokens.accentBg,
|
||||||
|
border: `1px solid ${tokens.borderSub}`,
|
||||||
|
color: tokens.textMuted, fontSize: 12, fontWeight: 600 }}>{selectedOrder.channel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI洞察条目 */}
|
||||||
|
{insightItems.map((item, i) => (
|
||||||
|
<div key={i} style={{ padding: '10px 12px', borderRadius: 10, background: `${item.color}06`, border: `1px solid ${item.color}22`, animation: `notice-in 0.3s ${i * 0.06}s both` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 18 }}>{item.icon}</span>
|
||||||
|
<span style={{ color: item.color, fontSize: 14, fontWeight: 700 }}>{item.title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.text, fontSize: 14, lineHeight: 1.7 }}>{item.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 利润排名 Tab */}
|
||||||
|
{activeTab === 'rank' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 13, marginBottom: 4 }}>按领用成本净利率排名</div>
|
||||||
|
{ranked.map((item, i) => {
|
||||||
|
const isSelected = selectedOrder?.id === item.order.id;
|
||||||
|
const barColor = item.netMargin >= 0.08 ? tokens.success : item.netMargin >= 0 ? tokens.warning : tokens.danger;
|
||||||
|
const barWidth = Math.min(100, Math.max(2, Math.abs(item.netMargin) * 500));
|
||||||
|
return (
|
||||||
|
<div key={item.order.id} style={{
|
||||||
|
padding: '7px 9px', borderRadius: 8, fontSize: 13,
|
||||||
|
border: `1px solid ${isSelected ? tokens.success + '40' : tokens.border}`,
|
||||||
|
background: isSelected ? tokens.successBg : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}>
|
||||||
|
<span style={{ color: i < 3 ? [tokens.warning, tokens.textMuted, tokens.textMuted][i] : tokens.textMuted, fontWeight: i < 3 ? 700 : 400, width: 16, textAlign: 'center', flexShrink: 0 }}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: isSelected ? tokens.success : tokens.text, fontWeight: isSelected ? 700 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{item.order.customer}
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 3, borderRadius: 2, background: tokens.border, marginTop: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${barWidth}%`, height: '100%', background: barColor, borderRadius: 2 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: barColor, fontWeight: 700, flexShrink: 0, fontSize: 14 }}>
|
||||||
|
{(item.netMargin * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 偏差分析 Tab */}
|
||||||
|
{activeTab === 'trend' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 13, marginBottom: 4 }}>各订单成本偏差汇总(领用 vs 标准)</div>
|
||||||
|
{ORDERS.map((o, i) => {
|
||||||
|
const stdRow = o.rows.find(r => r.type === '标准成本')!;
|
||||||
|
const useRow = o.rows.find(r => r.type === '领用成本')!;
|
||||||
|
const diffAmt = useRow.totalCost - stdRow.totalCost;
|
||||||
|
const diffPct = diffAmt / stdRow.totalCost;
|
||||||
|
const isSelected = selectedOrder?.id === o.id;
|
||||||
|
const c = diffAmt > 0 ? tokens.danger : tokens.success;
|
||||||
|
return (
|
||||||
|
<div key={o.id} style={{
|
||||||
|
padding: '9px 11px', borderRadius: 8, fontSize: 13,
|
||||||
|
border: `1px solid ${isSelected ? c + '50' : tokens.border}`,
|
||||||
|
background: isSelected ? `${c}08` : tokens.panelBgAlt,
|
||||||
|
animation: `notice-in 0.3s ${i * 0.04}s both`,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: isSelected ? c : tokens.text, fontWeight: 600, fontSize: 14 }}>{o.customer}</div>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 12, fontFamily: 'monospace', marginTop: 1 }}>{o.productCategory}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ color: c, fontWeight: 700, fontSize: 14 }}>
|
||||||
|
{diffAmt >= 0 ? '+' : ''}{Math.round(diffAmt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: c, fontSize: 12 }}>{(diffPct * 100).toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 5, height: 3, borderRadius: 2, background: tokens.borderSub, overflow: 'visible', position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', left: '50%', top: -1, width: 1, height: 5, background: tokens.borderAccent }} />
|
||||||
|
{diffAmt > 0 ? (
|
||||||
|
<div style={{ position: 'absolute', left: '50%', width: `${Math.min(50, Math.abs(diffPct) * 300)}%`, height: '100%', background: tokens.danger, borderRadius: '0 2px 2px 0', opacity: 0.7 }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ position: 'absolute', right: '50%', width: `${Math.min(50, Math.abs(diffPct) * 300)}%`, height: '100%', background: tokens.success, borderRadius: '2px 0 0 2px', opacity: 0.7 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI洞察生成逻辑 ──────────────────────────────────────────────────────────
|
||||||
|
type InsightTokens = { danger: string; warning: string; success: string; accent: string; textSub: string };
|
||||||
|
function generateInsights(order: Order, t: InsightTokens) {
|
||||||
|
const stdRow = order.rows.find(r => r.type === '标准成本')!;
|
||||||
|
const purRow = order.rows.find(r => r.type === '采购成本')!;
|
||||||
|
const useRow = order.rows.find(r => r.type === '领用成本')!;
|
||||||
|
|
||||||
|
const insights: { icon: React.ReactNode; title: string; content: string; color: string }[] = [];
|
||||||
|
|
||||||
|
// 净利润分析
|
||||||
|
const netM = useRow.netMargin;
|
||||||
|
if (netM < 0) {
|
||||||
|
insights.push({
|
||||||
|
icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke={t.danger} strokeWidth="1.4"/><path d="M8 4.5 L8 9" stroke={t.danger} strokeWidth="1.6" strokeLinecap="round"/><circle cx="8" cy="11" r="0.9" fill={t.danger}/></svg>, title: '亏损预警',
|
||||||
|
content: `领用成本口径净利润为 ${Math.round(useRow.netProfit).toLocaleString()} 元(净利率 ${(netM * 100).toFixed(1)}%),订单亏损。建议重新核查领用量与实际损耗,并评估是否调整下期报价策略。`,
|
||||||
|
color: t.danger,
|
||||||
|
});
|
||||||
|
} else if (netM < 0.05) {
|
||||||
|
insights.push({
|
||||||
|
icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2.5 L14 13 L2 13 Z" stroke={t.warning} strokeWidth="1.4" strokeLinejoin="round"/><path d="M8 6.5 L8 10" stroke={t.warning} strokeWidth="1.5" strokeLinecap="round"/><circle cx="8" cy="11.5" r="0.8" fill={t.warning}/></svg>, title: '毛利偏薄警示',
|
||||||
|
content: `领用成本净利率仅 ${(netM * 100).toFixed(1)}%,低于安全线5%。标准成本净利率为 ${(stdRow.netMargin * 100).toFixed(1)}%,实际利润空间较标准收窄 ${((stdRow.netMargin - netM) * 100).toFixed(1)} 个百分点。`,
|
||||||
|
color: t.warning,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
insights.push({
|
||||||
|
icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke={t.success} strokeWidth="1.4"/><path d="M5 8 L7 10.5 L11 5.5" stroke={t.success} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>, title: '盈利状况良好',
|
||||||
|
content: `领用成本净利率 ${(netM * 100).toFixed(1)}%,整体健康。三种成本口径下毛利率分别为:标准 ${(stdRow.grossMargin * 100).toFixed(1)}%、采购 ${(purRow.grossMargin * 100).toFixed(1)}%、领用 ${(useRow.grossMargin * 100).toFixed(1)}%。`,
|
||||||
|
color: t.success,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 采购成本偏差
|
||||||
|
const purDiff = purRow.totalCost - stdRow.totalCost;
|
||||||
|
const purDiffPct = purDiff / stdRow.totalCost;
|
||||||
|
if (Math.abs(purDiffPct) > 0.03) {
|
||||||
|
insights.push({
|
||||||
|
icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="6" width="11" height="8" rx="1.2" stroke="currentColor" strokeWidth="1.3"/><path d="M5 6 L5 4 C5 2.9 6 2 8 2 C10 2 11 2.9 11 4 L11 6" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/><path d="M2.5 9 L13.5 9" stroke="currentColor" strokeWidth="1" opacity="0.4"/></svg>, title: '采购成本偏差',
|
||||||
|
content: `采购成本较标准成本${purDiff >= 0 ? '超出' : '节省'} ${Math.abs(Math.round(purDiff)).toLocaleString()} 元(${purDiffPct >= 0 ? '+' : ''}${(purDiffPct * 100).toFixed(1)}%)。${purDiff > 0 ? '建议审查供应商价格谈判空间或寻找替代材料。' : '采购谈判效果显著,建议锁定优质供应商合作。'}`,
|
||||||
|
color: purDiff > 0 ? t.danger : t.success,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 领用成本偏差
|
||||||
|
const useDiff = useRow.totalCost - stdRow.totalCost;
|
||||||
|
const useDiffPct = useDiff / stdRow.totalCost;
|
||||||
|
if (Math.abs(useDiffPct) > 0.02) {
|
||||||
|
insights.push({
|
||||||
|
icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1.5" y="8" width="4" height="6" rx="0.8" stroke="currentColor" strokeWidth="1.3"/><rect x="6" y="5" width="4" height="9" rx="0.8" stroke="currentColor" strokeWidth="1.3"/><rect x="10.5" y="2.5" width="4" height="11.5" rx="0.8" stroke="currentColor" strokeWidth="1.3"/><path d="M1.5 8 L3.5 5 L8 7 L13 1.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" opacity="0.5"/></svg>, title: '领用成本偏差',
|
||||||
|
content: `实际领用成本较标准成本${useDiff >= 0 ? '偏高' : '偏低'} ${Math.abs(Math.round(useDiff)).toLocaleString()} 元(${useDiffPct >= 0 ? '+' : ''}${(useDiffPct * 100).toFixed(1)}%)。${useDiff > 0 ? '需核查是否存在超额领料、废料率偏高或人工工时核算偏差。' : '实际生产效率优于标准,建议沉淀优化经验更新标准成本。'}`,
|
||||||
|
color: useDiff > 0 ? t.danger : t.accent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 采购 vs 领用偏差
|
||||||
|
const purUseMatDiff = useRow.directMaterial - purRow.directMaterial;
|
||||||
|
if (Math.abs(purUseMatDiff) > 3000) {
|
||||||
|
insights.push({
|
||||||
|
icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.4"/><path d="M10.5 10.5 L14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/><path d="M5.5 7 L8.5 7 M7 5.5 L7 8.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>, title: '采购与领用材料偏差',
|
||||||
|
content: `领用材料成本(${Math.round(useRow.directMaterial).toLocaleString()} 元)与采购成本(${Math.round(purRow.directMaterial).toLocaleString()} 元)相差 ${Math.abs(Math.round(purUseMatDiff)).toLocaleString()} 元。${purUseMatDiff > 0 ? '领用量超出采购量,可能存在补领或库存核算差异,建议核对领料单与入库记录。' : '实际领用节约,可能存在退料或工艺优化,建议确认退料是否已入账。'}`,
|
||||||
|
color: t.warning,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销售报价建议
|
||||||
|
const revision = useRow.netMargin < 0.05 ? '建议在下期报价中将单价上调至少 ' + ((0.08 - useRow.netMargin) * 100).toFixed(1) + ' 个百分点以确保合理利润率。' : '当前报价结构合理,可作为同品类订单报价参考基准。';
|
||||||
|
insights.push({
|
||||||
|
icon: <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2 C5.2 2 3 4.2 3 7 C3 9 4.2 10.7 6 11.5 L6 13 L10 13 L10 11.5 C11.8 10.7 13 9 13 7 C13 4.2 10.8 2 8 2 Z" stroke={t.textSub} strokeWidth="1.3" strokeLinejoin="round"/><path d="M6 13.5 L10 13.5 M6.5 14.5 L9.5 14.5" stroke={t.textSub} strokeWidth="1.2" strokeLinecap="round"/><path d="M8 5 L8 9 M6 7 L10 7" stroke={t.textSub} strokeWidth="1.2" strokeLinecap="round"/></svg>, title: '销售报价建议',
|
||||||
|
content: `本订单客户 ${order.customer}(${order.channel}),品类:${order.productCategory},发货数量 ${order.rows[0].qty.toLocaleString()} 件,单价 ¥${order.rows[0].unitPrice}。${revision}`,
|
||||||
|
color: t.accent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return insights;
|
||||||
|
}
|
||||||
@@ -0,0 +1,912 @@
|
|||||||
|
// 制造业订单盈利能力分析数据模型
|
||||||
|
|
||||||
|
export type OrderStatus = 'open' | 'closed' | 'production' | 'shipped';
|
||||||
|
export type Channel = '外销-B2B' | '外销-B2C' | '内销-直销' | '内销-代理' | '电商平台';
|
||||||
|
export type RiskLevel = 'normal' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
export interface CostDetail {
|
||||||
|
// 直接材料
|
||||||
|
fabric: number; // 面料
|
||||||
|
accessories: number; // 辅料
|
||||||
|
// 直接人工
|
||||||
|
labor: number;
|
||||||
|
// 其他费用(制造费用)
|
||||||
|
overhead: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostRow {
|
||||||
|
type: '标准成本' | '采购成本' | '领用成本';
|
||||||
|
qty: number;
|
||||||
|
unitPrice: number;
|
||||||
|
revenue: number; // 销售收入(含税)
|
||||||
|
directMaterial: number; // 直接材料
|
||||||
|
directLabor: number; // 直接人工
|
||||||
|
manufacturingCost: number; // 制造费用
|
||||||
|
totalCost: number; // 制造成本合计
|
||||||
|
grossProfit: number; // 毛利
|
||||||
|
grossMargin: number; // 毛利率 (小数)
|
||||||
|
mgmtExpense: number; // 管理销研费用
|
||||||
|
netProfit: number; // 净利润
|
||||||
|
netMargin: number; // 净利率 (小数)
|
||||||
|
detail: CostDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteCostItem {
|
||||||
|
name: string;
|
||||||
|
quotedPrice: number; // 报价成本单价
|
||||||
|
quantity: number;
|
||||||
|
totalCost: number;
|
||||||
|
percentage: number; // 占报价比例
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostSubItem {
|
||||||
|
label: string;
|
||||||
|
stdCost: number;
|
||||||
|
stdPct: number;
|
||||||
|
purchaseCost: number;
|
||||||
|
purchasePct: number;
|
||||||
|
usageCost: number;
|
||||||
|
usagePct: number;
|
||||||
|
purchaseVsStdAmt: number;
|
||||||
|
purchaseVsStdPct: number;
|
||||||
|
usageVsStdAmt: number;
|
||||||
|
usageVsStdPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostBreakdown {
|
||||||
|
category: string;
|
||||||
|
items: CostSubItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: string;
|
||||||
|
orderNo: string; // 制造令号
|
||||||
|
customer: string; // 客户
|
||||||
|
salesperson: string; // 业务员
|
||||||
|
channel: Channel;
|
||||||
|
closedDate: string; // 结单日期
|
||||||
|
productCategory: string; // 品类
|
||||||
|
status: OrderStatus;
|
||||||
|
riskLevel: RiskLevel;
|
||||||
|
region: string;
|
||||||
|
rows: CostRow[]; // 标准成本/采购成本/领用成本三行
|
||||||
|
costBreakdown: CostBreakdown[]; // 成本明细附表
|
||||||
|
quoteCostItems?: QuoteCostItem[]; // 与客户报价成本细项对比
|
||||||
|
tags: string[];
|
||||||
|
submittedAt: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 订单生成器 ────────────────────────────────────────────────────────────────
|
||||||
|
function genOrder(p: {
|
||||||
|
id: string; orderNo: string; customer: string; salesperson: string;
|
||||||
|
channel: Channel; closedDate: string; productCategory: string;
|
||||||
|
status: OrderStatus; riskLevel: RiskLevel; region: string;
|
||||||
|
tags: string[]; submittedAt: string; note?: string;
|
||||||
|
qty: number; unitPrice: number;
|
||||||
|
stdMat: number; stdLabor: number; stdOH: number;
|
||||||
|
purMatAdj: number; useMatAdj: number; useLaborAdj: number;
|
||||||
|
mgmt: number; matSplit?: [number, number];
|
||||||
|
}): Order {
|
||||||
|
const revenue = Math.round(p.qty * p.unitPrice);
|
||||||
|
const sp = p.matSplit || [0.6, 0.4];
|
||||||
|
const mkRow = (type: '标准成本'|'采购成本'|'领用成本', matA: number, labA: number): CostRow => {
|
||||||
|
const dm = Math.round(p.stdMat * (1 + matA));
|
||||||
|
const dl = Math.round(p.stdLabor * (1 + labA));
|
||||||
|
const mc = p.stdOH; const tc = dm + dl + mc;
|
||||||
|
const gp = revenue - tc; const gm = revenue > 0 ? gp / revenue : 0;
|
||||||
|
const np = gp - p.mgmt; const nm = revenue > 0 ? np / revenue : 0;
|
||||||
|
return { type, qty: p.qty, unitPrice: p.unitPrice, revenue, directMaterial: dm, directLabor: dl,
|
||||||
|
manufacturingCost: mc, totalCost: tc, grossProfit: gp, grossMargin: +gm.toFixed(4),
|
||||||
|
mgmtExpense: p.mgmt, netProfit: np, netMargin: +nm.toFixed(4),
|
||||||
|
detail: { fabric: Math.round(dm * sp[0]), accessories: Math.round(dm * sp[1]), labor: dl, overhead: mc } };
|
||||||
|
};
|
||||||
|
const std = mkRow('标准成本', 0, 0);
|
||||||
|
const pur = mkRow('采购成本', p.purMatAdj, 0);
|
||||||
|
const use = mkRow('领用成本', p.useMatAdj, p.useLaborAdj);
|
||||||
|
const bd = (_r: CostRow): CostBreakdown[] => [
|
||||||
|
{ category: '直接材料', items: [
|
||||||
|
{ label: '面料', stdCost: std.detail.fabric, stdPct: std.detail.fabric/revenue,
|
||||||
|
purchaseCost: pur.detail.fabric, purchasePct: pur.detail.fabric/revenue,
|
||||||
|
usageCost: use.detail.fabric, usagePct: use.detail.fabric/revenue,
|
||||||
|
purchaseVsStdAmt: pur.detail.fabric-std.detail.fabric, purchaseVsStdPct: (pur.detail.fabric-std.detail.fabric)/(std.detail.fabric||1),
|
||||||
|
usageVsStdAmt: use.detail.fabric-std.detail.fabric, usageVsStdPct: (use.detail.fabric-std.detail.fabric)/(std.detail.fabric||1) },
|
||||||
|
{ label: '辅料', stdCost: std.detail.accessories, stdPct: std.detail.accessories/revenue,
|
||||||
|
purchaseCost: pur.detail.accessories, purchasePct: pur.detail.accessories/revenue,
|
||||||
|
usageCost: use.detail.accessories, usagePct: use.detail.accessories/revenue,
|
||||||
|
purchaseVsStdAmt: pur.detail.accessories-std.detail.accessories, purchaseVsStdPct: (pur.detail.accessories-std.detail.accessories)/(std.detail.accessories||1),
|
||||||
|
usageVsStdAmt: use.detail.accessories-std.detail.accessories, usageVsStdPct: (use.detail.accessories-std.detail.accessories)/(std.detail.accessories||1) },
|
||||||
|
]},
|
||||||
|
{ category: '直接人工', items: [
|
||||||
|
{ label: '人工', stdCost: std.directLabor, stdPct: std.directLabor/revenue,
|
||||||
|
purchaseCost: pur.directLabor, purchasePct: pur.directLabor/revenue,
|
||||||
|
usageCost: use.directLabor, usagePct: use.directLabor/revenue,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: use.directLabor-std.directLabor, usageVsStdPct: (use.directLabor-std.directLabor)/(std.directLabor||1) },
|
||||||
|
]},
|
||||||
|
{ category: '其他费用', items: [
|
||||||
|
{ label: '费用', stdCost: p.stdOH, stdPct: 0, purchaseCost: p.stdOH, purchasePct: 0,
|
||||||
|
usageCost: p.stdOH, usagePct: 0, purchaseVsStdAmt: 0, purchaseVsStdPct: 0, usageVsStdAmt: 0, usageVsStdPct: 0 },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
return { ...p, rows: [std, pur, use], costBreakdown: bd(std) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 订单数据 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ORDERS: Order[] = [
|
||||||
|
{
|
||||||
|
id: 'ORD001',
|
||||||
|
orderNo: 'F02-XM02-2506190001',
|
||||||
|
customer: 'ATB',
|
||||||
|
salesperson: '吴海艳',
|
||||||
|
channel: '外销-B2B',
|
||||||
|
closedDate: '2026-04-07',
|
||||||
|
productCategory: '摄影幕布',
|
||||||
|
status: 'closed',
|
||||||
|
riskLevel: 'warning',
|
||||||
|
region: '华东区',
|
||||||
|
tags: ['摄影周边', '外销主力', '成本偏差'],
|
||||||
|
submittedAt: '2026-06-10',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
type: '标准成本',
|
||||||
|
qty: 9728, unitPrice: 39.3442, revenue: 382741,
|
||||||
|
directMaterial: 216539, directLabor: 64210, manufacturingCost: 58609, totalCost: 339358,
|
||||||
|
grossProfit: 43383, grossMargin: 0.1133,
|
||||||
|
mgmtExpense: 45928.92, netProfit: -2546, netMargin: -0.01,
|
||||||
|
detail: { fabric: 120616.74, accessories: 95922.26, labor: 64210, overhead: 58609 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '采购成本',
|
||||||
|
qty: 9728, unitPrice: 39.3442, revenue: 382741,
|
||||||
|
directMaterial: 194690, directLabor: 64210, manufacturingCost: 58609, totalCost: 317509,
|
||||||
|
grossProfit: 65232, grossMargin: 0.17,
|
||||||
|
mgmtExpense: 45928.92, netProfit: 19303, netMargin: 0.05,
|
||||||
|
detail: { fabric: 125501.5, accessories: 69188.5, labor: 64210, overhead: 58609 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '领用成本',
|
||||||
|
qty: 9728, unitPrice: 39.3442, revenue: 382741,
|
||||||
|
directMaterial: 196476, directLabor: 53676, manufacturingCost: 58609, totalCost: 308762,
|
||||||
|
grossProfit: 73979, grossMargin: 0.19,
|
||||||
|
mgmtExpense: 45928.92, netProfit: 28050, netMargin: 0.07,
|
||||||
|
detail: { fabric: 106521.6, accessories: 89954.4, labor: 53676, overhead: 58609 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
costBreakdown: [
|
||||||
|
{
|
||||||
|
category: '直接材料',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '面料',
|
||||||
|
stdCost: 120616.74, stdPct: 0.3151,
|
||||||
|
purchaseCost: 125501.5, purchasePct: 0.3279,
|
||||||
|
usageCost: 106521.6, usagePct: 0.2783,
|
||||||
|
purchaseVsStdAmt: 4884.76, purchaseVsStdPct: 0.01,
|
||||||
|
usageVsStdAmt: -14095.14, usageVsStdPct: -0.04,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '辅料',
|
||||||
|
stdCost: 95922.26, stdPct: 0.2506,
|
||||||
|
purchaseCost: 69188.5, purchasePct: 0.1808,
|
||||||
|
usageCost: 89954.4, usagePct: 0.235,
|
||||||
|
purchaseVsStdAmt: -26734.49, purchaseVsStdPct: -0.07,
|
||||||
|
usageVsStdAmt: -5967.82, usageVsStdPct: -0.02,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '直接人工',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '人工',
|
||||||
|
stdCost: 64209.69, stdPct: 0.1678,
|
||||||
|
purchaseCost: 64209.69, purchasePct: 0.1678,
|
||||||
|
usageCost: 53675.98, usagePct: 0.1402,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: -10533.71, usageVsStdPct: -0.03,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '其他费用',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '费用',
|
||||||
|
stdCost: 58609.19, stdPct: 0,
|
||||||
|
purchaseCost: 58609.19, purchasePct: 0,
|
||||||
|
usageCost: 58609.19, usagePct: 0,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: 0, usageVsStdPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quoteCostItems: [
|
||||||
|
{ name: '面料', quotedPrice: 12.4, quantity: 9728, totalCost: 120672, percentage: 0.3152 },
|
||||||
|
{ name: '辅料', quotedPrice: 9.87, quantity: 9728, totalCost: 96026, percentage: 0.2509 },
|
||||||
|
{ name: '人工', quotedPrice: 6.60, quantity: 9728, totalCost: 64205, percentage: 0.1677 },
|
||||||
|
{ name: '制造费用', quotedPrice: 6.02, quantity: 9728, totalCost: 58561, percentage: 0.1530 },
|
||||||
|
],
|
||||||
|
note: '面料采购成本高于标准成本1%,辅料采购低于标准7%;领用成本中人工节省10533元,需关注领用辅料偏差原因。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ORD002',
|
||||||
|
orderNo: 'F02-XM03-2506190012',
|
||||||
|
customer: 'PhotoPro Inc.',
|
||||||
|
salesperson: '陈建伟',
|
||||||
|
channel: '外销-B2B',
|
||||||
|
closedDate: '2026-04-15',
|
||||||
|
productCategory: '影棚背景布',
|
||||||
|
status: 'closed',
|
||||||
|
riskLevel: 'normal',
|
||||||
|
region: '华东区',
|
||||||
|
tags: ['背景布', '外销稳定', '标准成本对齐'],
|
||||||
|
submittedAt: '2026-06-09',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
type: '标准成本',
|
||||||
|
qty: 5520, unitPrice: 45.8, revenue: 252816,
|
||||||
|
directMaterial: 128340, directLabor: 38200, manufacturingCost: 35100, totalCost: 201640,
|
||||||
|
grossProfit: 51176, grossMargin: 0.2025,
|
||||||
|
mgmtExpense: 30200, netProfit: 20976, netMargin: 0.083,
|
||||||
|
detail: { fabric: 82400, accessories: 45940, labor: 38200, overhead: 35100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '采购成本',
|
||||||
|
qty: 5520, unitPrice: 45.8, revenue: 252816,
|
||||||
|
directMaterial: 124890, directLabor: 38200, manufacturingCost: 35100, totalCost: 198190,
|
||||||
|
grossProfit: 54626, grossMargin: 0.216,
|
||||||
|
mgmtExpense: 30200, netProfit: 24426, netMargin: 0.097,
|
||||||
|
detail: { fabric: 80100, accessories: 44790, labor: 38200, overhead: 35100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '领用成本',
|
||||||
|
qty: 5520, unitPrice: 45.8, revenue: 252816,
|
||||||
|
directMaterial: 126200, directLabor: 36800, manufacturingCost: 35100, totalCost: 198100,
|
||||||
|
grossProfit: 54716, grossMargin: 0.2164,
|
||||||
|
mgmtExpense: 30200, netProfit: 24516, netMargin: 0.097,
|
||||||
|
detail: { fabric: 81500, accessories: 44700, labor: 36800, overhead: 35100 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
costBreakdown: [
|
||||||
|
{
|
||||||
|
category: '直接材料',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '面料',
|
||||||
|
stdCost: 82400, stdPct: 0.326,
|
||||||
|
purchaseCost: 80100, purchasePct: 0.317,
|
||||||
|
usageCost: 81500, usagePct: 0.322,
|
||||||
|
purchaseVsStdAmt: -2300, purchaseVsStdPct: -0.009,
|
||||||
|
usageVsStdAmt: -900, usageVsStdPct: -0.004,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '辅料',
|
||||||
|
stdCost: 45940, stdPct: 0.182,
|
||||||
|
purchaseCost: 44790, purchasePct: 0.177,
|
||||||
|
usageCost: 44700, usagePct: 0.177,
|
||||||
|
purchaseVsStdAmt: -1150, purchaseVsStdPct: -0.005,
|
||||||
|
usageVsStdAmt: -1240, usageVsStdPct: -0.005,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '直接人工',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '人工',
|
||||||
|
stdCost: 38200, stdPct: 0.151,
|
||||||
|
purchaseCost: 38200, purchasePct: 0.151,
|
||||||
|
usageCost: 36800, usagePct: 0.1455,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: -1400, usageVsStdPct: -0.006,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '其他费用',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '费用',
|
||||||
|
stdCost: 35100, stdPct: 0,
|
||||||
|
purchaseCost: 35100, purchasePct: 0,
|
||||||
|
usageCost: 35100, usagePct: 0,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: 0, usageVsStdPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ORD003',
|
||||||
|
orderNo: 'F03-NM01-2506200045',
|
||||||
|
customer: '万博传媒科技',
|
||||||
|
salesperson: '李晓婷',
|
||||||
|
channel: '内销-直销',
|
||||||
|
closedDate: '2026-05-10',
|
||||||
|
productCategory: '摄影幕布',
|
||||||
|
status: 'shipped',
|
||||||
|
riskLevel: 'danger',
|
||||||
|
region: '华南区',
|
||||||
|
tags: ['内销重点', '高风险', '领用超标'],
|
||||||
|
submittedAt: '2026-06-08',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
type: '标准成本',
|
||||||
|
qty: 3200, unitPrice: 52.5, revenue: 168000,
|
||||||
|
directMaterial: 98400, directLabor: 24500, manufacturingCost: 18200, totalCost: 141100,
|
||||||
|
grossProfit: 26900, grossMargin: 0.1601,
|
||||||
|
mgmtExpense: 22000, netProfit: 4900, netMargin: 0.029,
|
||||||
|
detail: { fabric: 62800, accessories: 35600, labor: 24500, overhead: 18200 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '采购成本',
|
||||||
|
qty: 3200, unitPrice: 52.5, revenue: 168000,
|
||||||
|
directMaterial: 96200, directLabor: 24500, manufacturingCost: 18200, totalCost: 138900,
|
||||||
|
grossProfit: 29100, grossMargin: 0.1732,
|
||||||
|
mgmtExpense: 22000, netProfit: 7100, netMargin: 0.042,
|
||||||
|
detail: { fabric: 61500, accessories: 34700, labor: 24500, overhead: 18200 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '领用成本',
|
||||||
|
qty: 3200, unitPrice: 52.5, revenue: 168000,
|
||||||
|
directMaterial: 112600, directLabor: 27300, manufacturingCost: 18200, totalCost: 158100,
|
||||||
|
grossProfit: 9900, grossMargin: 0.0589,
|
||||||
|
mgmtExpense: 22000, netProfit: -12100, netMargin: -0.072,
|
||||||
|
detail: { fabric: 71200, accessories: 41400, labor: 27300, overhead: 18200 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
costBreakdown: [
|
||||||
|
{
|
||||||
|
category: '直接材料',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '面料',
|
||||||
|
stdCost: 62800, stdPct: 0.374,
|
||||||
|
purchaseCost: 61500, purchasePct: 0.366,
|
||||||
|
usageCost: 71200, usagePct: 0.424,
|
||||||
|
purchaseVsStdAmt: -1300, purchaseVsStdPct: -0.008,
|
||||||
|
usageVsStdAmt: 8400, usageVsStdPct: 0.05,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '辅料',
|
||||||
|
stdCost: 35600, stdPct: 0.212,
|
||||||
|
purchaseCost: 34700, purchasePct: 0.207,
|
||||||
|
usageCost: 41400, usagePct: 0.246,
|
||||||
|
purchaseVsStdAmt: -900, purchaseVsStdPct: -0.005,
|
||||||
|
usageVsStdAmt: 5800, usageVsStdPct: 0.035,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '直接人工',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '人工',
|
||||||
|
stdCost: 24500, stdPct: 0.146,
|
||||||
|
purchaseCost: 24500, purchasePct: 0.146,
|
||||||
|
usageCost: 27300, usagePct: 0.162,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: 2800, usageVsStdPct: 0.017,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '其他费用',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '费用',
|
||||||
|
stdCost: 18200, stdPct: 0,
|
||||||
|
purchaseCost: 18200, purchasePct: 0,
|
||||||
|
usageCost: 18200, usagePct: 0,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: 0, usageVsStdPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
note: '领用成本远超标准成本:面料领用超标8400元(+5%),辅料超标5800元(+3.5%),人工超标2800元,需重点核查领料记录与生产损耗率。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ORD004',
|
||||||
|
orderNo: 'F01-EU04-2504080088',
|
||||||
|
customer: 'Studio Gear EU',
|
||||||
|
salesperson: '吴海艳',
|
||||||
|
channel: '外销-B2B',
|
||||||
|
closedDate: '2026-03-22',
|
||||||
|
productCategory: '便携背景架',
|
||||||
|
status: 'closed',
|
||||||
|
riskLevel: 'normal',
|
||||||
|
region: '欧洲区',
|
||||||
|
tags: ['架类产品', '外销欧洲', '成本稳定'],
|
||||||
|
submittedAt: '2026-06-07',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
type: '标准成本',
|
||||||
|
qty: 2100, unitPrice: 88.6, revenue: 186060,
|
||||||
|
directMaterial: 98200, directLabor: 28400, manufacturingCost: 22100, totalCost: 148700,
|
||||||
|
grossProfit: 37360, grossMargin: 0.2008,
|
||||||
|
mgmtExpense: 24500, netProfit: 12860, netMargin: 0.069,
|
||||||
|
detail: { fabric: 45200, accessories: 53000, labor: 28400, overhead: 22100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '采购成本',
|
||||||
|
qty: 2100, unitPrice: 88.6, revenue: 186060,
|
||||||
|
directMaterial: 95600, directLabor: 28400, manufacturingCost: 22100, totalCost: 146100,
|
||||||
|
grossProfit: 39960, grossMargin: 0.2148,
|
||||||
|
mgmtExpense: 24500, netProfit: 15460, netMargin: 0.083,
|
||||||
|
detail: { fabric: 43800, accessories: 51800, labor: 28400, overhead: 22100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '领用成本',
|
||||||
|
qty: 2100, unitPrice: 88.6, revenue: 186060,
|
||||||
|
directMaterial: 96400, directLabor: 27600, manufacturingCost: 22100, totalCost: 146100,
|
||||||
|
grossProfit: 39960, grossMargin: 0.2148,
|
||||||
|
mgmtExpense: 24500, netProfit: 15460, netMargin: 0.083,
|
||||||
|
detail: { fabric: 44500, accessories: 51900, labor: 27600, overhead: 22100 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
costBreakdown: [
|
||||||
|
{
|
||||||
|
category: '直接材料',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '主材',
|
||||||
|
stdCost: 45200, stdPct: 0.243,
|
||||||
|
purchaseCost: 43800, purchasePct: 0.235,
|
||||||
|
usageCost: 44500, usagePct: 0.239,
|
||||||
|
purchaseVsStdAmt: -1400, purchaseVsStdPct: -0.008,
|
||||||
|
usageVsStdAmt: -700, usageVsStdPct: -0.004,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '配件',
|
||||||
|
stdCost: 53000, stdPct: 0.285,
|
||||||
|
purchaseCost: 51800, purchasePct: 0.278,
|
||||||
|
usageCost: 51900, usagePct: 0.279,
|
||||||
|
purchaseVsStdAmt: -1200, purchaseVsStdPct: -0.006,
|
||||||
|
usageVsStdAmt: -1100, usageVsStdPct: -0.006,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '直接人工',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '人工',
|
||||||
|
stdCost: 28400, stdPct: 0.153,
|
||||||
|
purchaseCost: 28400, purchasePct: 0.153,
|
||||||
|
usageCost: 27600, usagePct: 0.148,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: -800, usageVsStdPct: -0.004,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '其他费用',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '费用',
|
||||||
|
stdCost: 22100, stdPct: 0,
|
||||||
|
purchaseCost: 22100, purchasePct: 0,
|
||||||
|
usageCost: 22100, usagePct: 0,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: 0, usageVsStdPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ORD005',
|
||||||
|
orderNo: 'F02-US05-2506120099',
|
||||||
|
customer: 'Amazon US Direct',
|
||||||
|
salesperson: '张明华',
|
||||||
|
channel: '外销-B2C',
|
||||||
|
closedDate: '2026-05-30',
|
||||||
|
productCategory: '摄影幕布套装',
|
||||||
|
status: 'production',
|
||||||
|
riskLevel: 'warning',
|
||||||
|
region: '北美区',
|
||||||
|
tags: ['套装', '电商外销', '毛利监控'],
|
||||||
|
submittedAt: '2026-06-05',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
type: '标准成本',
|
||||||
|
qty: 15600, unitPrice: 28.9, revenue: 450840,
|
||||||
|
directMaterial: 258400, directLabor: 72100, manufacturingCost: 65300, totalCost: 395800,
|
||||||
|
grossProfit: 55040, grossMargin: 0.1221,
|
||||||
|
mgmtExpense: 52000, netProfit: 3040, netMargin: 0.007,
|
||||||
|
detail: { fabric: 164200, accessories: 94200, labor: 72100, overhead: 65300 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '采购成本',
|
||||||
|
qty: 15600, unitPrice: 28.9, revenue: 450840,
|
||||||
|
directMaterial: 248600, directLabor: 72100, manufacturingCost: 65300, totalCost: 386000,
|
||||||
|
grossProfit: 64840, grossMargin: 0.1438,
|
||||||
|
mgmtExpense: 52000, netProfit: 12840, netMargin: 0.028,
|
||||||
|
detail: { fabric: 158400, accessories: 90200, labor: 72100, overhead: 65300 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '领用成本',
|
||||||
|
qty: 15600, unitPrice: 28.9, revenue: 450840,
|
||||||
|
directMaterial: 254800, directLabor: 70200, manufacturingCost: 65300, totalCost: 390300,
|
||||||
|
grossProfit: 60540, grossMargin: 0.1343,
|
||||||
|
mgmtExpense: 52000, netProfit: 8540, netMargin: 0.019,
|
||||||
|
detail: { fabric: 162100, accessories: 92700, labor: 70200, overhead: 65300 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
costBreakdown: [
|
||||||
|
{
|
||||||
|
category: '直接材料',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '面料',
|
||||||
|
stdCost: 164200, stdPct: 0.3642,
|
||||||
|
purchaseCost: 158400, purchasePct: 0.3514,
|
||||||
|
usageCost: 162100, usagePct: 0.3596,
|
||||||
|
purchaseVsStdAmt: -5800, purchaseVsStdPct: -0.013,
|
||||||
|
usageVsStdAmt: -2100, usageVsStdPct: -0.005,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '辅料',
|
||||||
|
stdCost: 94200, stdPct: 0.209,
|
||||||
|
purchaseCost: 90200, purchasePct: 0.2001,
|
||||||
|
usageCost: 92700, usagePct: 0.2057,
|
||||||
|
purchaseVsStdAmt: -4000, purchaseVsStdPct: -0.009,
|
||||||
|
usageVsStdAmt: -1500, usageVsStdPct: -0.003,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '直接人工',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '人工',
|
||||||
|
stdCost: 72100, stdPct: 0.1599,
|
||||||
|
purchaseCost: 72100, purchasePct: 0.1599,
|
||||||
|
usageCost: 70200, usagePct: 0.1557,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: -1900, usageVsStdPct: -0.004,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '其他费用',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '费用',
|
||||||
|
stdCost: 65300, stdPct: 0,
|
||||||
|
purchaseCost: 65300, purchasePct: 0,
|
||||||
|
usageCost: 65300, usagePct: 0,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: 0, usageVsStdPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
note: '大批量低单价套装,毛利薄。标准成本毛利率仅12.2%,净利率0.7%接近保本。需密切关注领用成本,一旦超标即亏损。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ORD006',
|
||||||
|
orderNo: 'F01-JP06-2505300021',
|
||||||
|
customer: 'Hakuba JP',
|
||||||
|
salesperson: '陈建伟',
|
||||||
|
channel: '外销-B2B',
|
||||||
|
closedDate: '2026-04-28',
|
||||||
|
productCategory: '无缝背景纸',
|
||||||
|
status: 'closed',
|
||||||
|
riskLevel: 'normal',
|
||||||
|
region: '日本区',
|
||||||
|
tags: ['纸类产品', '外销日本', '高毛利'],
|
||||||
|
submittedAt: '2026-06-04',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
type: '标准成本',
|
||||||
|
qty: 4800, unitPrice: 62.4, revenue: 299520,
|
||||||
|
directMaterial: 112600, directLabor: 42000, manufacturingCost: 38500, totalCost: 193100,
|
||||||
|
grossProfit: 106420, grossMargin: 0.3553,
|
||||||
|
mgmtExpense: 32000, netProfit: 74420, netMargin: 0.2485,
|
||||||
|
detail: { fabric: 88400, accessories: 24200, labor: 42000, overhead: 38500 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '采购成本',
|
||||||
|
qty: 4800, unitPrice: 62.4, revenue: 299520,
|
||||||
|
directMaterial: 108900, directLabor: 42000, manufacturingCost: 38500, totalCost: 189400,
|
||||||
|
grossProfit: 110120, grossMargin: 0.3677,
|
||||||
|
mgmtExpense: 32000, netProfit: 78120, netMargin: 0.2609,
|
||||||
|
detail: { fabric: 85600, accessories: 23300, labor: 42000, overhead: 38500 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '领用成本',
|
||||||
|
qty: 4800, unitPrice: 62.4, revenue: 299520,
|
||||||
|
directMaterial: 110200, directLabor: 41200, manufacturingCost: 38500, totalCost: 189900,
|
||||||
|
grossProfit: 109620, grossMargin: 0.366,
|
||||||
|
mgmtExpense: 32000, netProfit: 77620, netMargin: 0.2591,
|
||||||
|
detail: { fabric: 86900, accessories: 23300, labor: 41200, overhead: 38500 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
costBreakdown: [
|
||||||
|
{
|
||||||
|
category: '直接材料',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '主材',
|
||||||
|
stdCost: 88400, stdPct: 0.2952,
|
||||||
|
purchaseCost: 85600, purchasePct: 0.286,
|
||||||
|
usageCost: 86900, usagePct: 0.2903,
|
||||||
|
purchaseVsStdAmt: -2800, purchaseVsStdPct: -0.009,
|
||||||
|
usageVsStdAmt: -1500, usageVsStdPct: -0.005,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '辅料',
|
||||||
|
stdCost: 24200, stdPct: 0.0808,
|
||||||
|
purchaseCost: 23300, purchasePct: 0.0778,
|
||||||
|
usageCost: 23300, usagePct: 0.0778,
|
||||||
|
purchaseVsStdAmt: -900, purchaseVsStdPct: -0.003,
|
||||||
|
usageVsStdAmt: -900, usageVsStdPct: -0.003,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '直接人工',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '人工',
|
||||||
|
stdCost: 42000, stdPct: 0.1402,
|
||||||
|
purchaseCost: 42000, purchasePct: 0.1402,
|
||||||
|
usageCost: 41200, usagePct: 0.1376,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: -800, usageVsStdPct: -0.003,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '其他费用',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '费用',
|
||||||
|
stdCost: 38500, stdPct: 0,
|
||||||
|
purchaseCost: 38500, purchasePct: 0,
|
||||||
|
usageCost: 38500, usagePct: 0,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: 0, usageVsStdPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ORD007',
|
||||||
|
orderNo: 'F03-KR07-2506010055',
|
||||||
|
customer: 'Kenko Korea',
|
||||||
|
salesperson: '吴海艳',
|
||||||
|
channel: '外销-B2B',
|
||||||
|
closedDate: '2026-05-05',
|
||||||
|
productCategory: '绿幕套装',
|
||||||
|
status: 'closed',
|
||||||
|
riskLevel: 'warning',
|
||||||
|
region: '韩国区',
|
||||||
|
tags: ['绿幕', '特殊品类', '价格压力'],
|
||||||
|
submittedAt: '2026-06-03',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
type: '标准成本',
|
||||||
|
qty: 6720, unitPrice: 35.2, revenue: 236544,
|
||||||
|
directMaterial: 145600, directLabor: 38800, manufacturingCost: 32400, totalCost: 216800,
|
||||||
|
grossProfit: 19744, grossMargin: 0.0835,
|
||||||
|
mgmtExpense: 28000, netProfit: -8256, netMargin: -0.035,
|
||||||
|
detail: { fabric: 98200, accessories: 47400, labor: 38800, overhead: 32400 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '采购成本',
|
||||||
|
qty: 6720, unitPrice: 35.2, revenue: 236544,
|
||||||
|
directMaterial: 139800, directLabor: 38800, manufacturingCost: 32400, totalCost: 211000,
|
||||||
|
grossProfit: 25544, grossMargin: 0.108,
|
||||||
|
mgmtExpense: 28000, netProfit: -2456, netMargin: -0.01,
|
||||||
|
detail: { fabric: 94500, accessories: 45300, labor: 38800, overhead: 32400 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '领用成本',
|
||||||
|
qty: 6720, unitPrice: 35.2, revenue: 236544,
|
||||||
|
directMaterial: 142000, directLabor: 37600, manufacturingCost: 32400, totalCost: 212000,
|
||||||
|
grossProfit: 24544, grossMargin: 0.1038,
|
||||||
|
mgmtExpense: 28000, netProfit: -3456, netMargin: -0.015,
|
||||||
|
detail: { fabric: 96200, accessories: 45800, labor: 37600, overhead: 32400 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
costBreakdown: [
|
||||||
|
{
|
||||||
|
category: '直接材料',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '面料',
|
||||||
|
stdCost: 98200, stdPct: 0.415,
|
||||||
|
purchaseCost: 94500, purchasePct: 0.3997,
|
||||||
|
usageCost: 96200, usagePct: 0.4068,
|
||||||
|
purchaseVsStdAmt: -3700, purchaseVsStdPct: -0.016,
|
||||||
|
usageVsStdAmt: -2000, usageVsStdPct: -0.008,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '辅料',
|
||||||
|
stdCost: 47400, stdPct: 0.2003,
|
||||||
|
purchaseCost: 45300, purchasePct: 0.1914,
|
||||||
|
usageCost: 45800, usagePct: 0.1936,
|
||||||
|
purchaseVsStdAmt: -2100, purchaseVsStdPct: -0.009,
|
||||||
|
usageVsStdAmt: -1600, usageVsStdPct: -0.007,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '直接人工',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '人工',
|
||||||
|
stdCost: 38800, stdPct: 0.164,
|
||||||
|
purchaseCost: 38800, purchasePct: 0.164,
|
||||||
|
usageCost: 37600, usagePct: 0.159,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: -1200, usageVsStdPct: -0.005,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '其他费用',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '费用',
|
||||||
|
stdCost: 32400, stdPct: 0,
|
||||||
|
purchaseCost: 32400, purchasePct: 0,
|
||||||
|
usageCost: 32400, usagePct: 0,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: 0, usageVsStdPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
note: '低报价导致三种成本口径下净利润均为负值,标准成本下净亏损8256元。需重新评估与该客户的报价策略。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ORD008',
|
||||||
|
orderNo: 'F01-CN08-2506150078',
|
||||||
|
customer: '百联传媒集团',
|
||||||
|
salesperson: '李晓婷',
|
||||||
|
channel: '内销-代理',
|
||||||
|
closedDate: '',
|
||||||
|
productCategory: '直播背景墙',
|
||||||
|
status: 'production',
|
||||||
|
riskLevel: 'normal',
|
||||||
|
region: '华北区',
|
||||||
|
tags: ['直播经济', '内销代理', '在产'],
|
||||||
|
submittedAt: '2026-06-01',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
type: '标准成本',
|
||||||
|
qty: 1800, unitPrice: 128.0, revenue: 230400,
|
||||||
|
directMaterial: 108600, directLabor: 32100, manufacturingCost: 28800, totalCost: 169500,
|
||||||
|
grossProfit: 60900, grossMargin: 0.2643,
|
||||||
|
mgmtExpense: 25000, netProfit: 35900, netMargin: 0.1558,
|
||||||
|
detail: { fabric: 68200, accessories: 40400, labor: 32100, overhead: 28800 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '采购成本',
|
||||||
|
qty: 1800, unitPrice: 128.0, revenue: 230400,
|
||||||
|
directMaterial: 105200, directLabor: 32100, manufacturingCost: 28800, totalCost: 166100,
|
||||||
|
grossProfit: 64300, grossMargin: 0.279,
|
||||||
|
mgmtExpense: 25000, netProfit: 39300, netMargin: 0.1706,
|
||||||
|
detail: { fabric: 66100, accessories: 39100, labor: 32100, overhead: 28800 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: '领用成本',
|
||||||
|
qty: 1800, unitPrice: 128.0, revenue: 230400,
|
||||||
|
directMaterial: 106800, directLabor: 31400, manufacturingCost: 28800, totalCost: 167000,
|
||||||
|
grossProfit: 63400, grossMargin: 0.2752,
|
||||||
|
mgmtExpense: 25000, netProfit: 38400, netMargin: 0.1667,
|
||||||
|
detail: { fabric: 67200, accessories: 39600, labor: 31400, overhead: 28800 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
costBreakdown: [
|
||||||
|
{
|
||||||
|
category: '直接材料',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '面料',
|
||||||
|
stdCost: 68200, stdPct: 0.296,
|
||||||
|
purchaseCost: 66100, purchasePct: 0.287,
|
||||||
|
usageCost: 67200, usagePct: 0.292,
|
||||||
|
purchaseVsStdAmt: -2100, purchaseVsStdPct: -0.009,
|
||||||
|
usageVsStdAmt: -1000, usageVsStdPct: -0.004,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '辅料',
|
||||||
|
stdCost: 40400, stdPct: 0.175,
|
||||||
|
purchaseCost: 39100, purchasePct: 0.17,
|
||||||
|
usageCost: 39600, usagePct: 0.172,
|
||||||
|
purchaseVsStdAmt: -1300, purchaseVsStdPct: -0.006,
|
||||||
|
usageVsStdAmt: -800, usageVsStdPct: -0.003,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '直接人工',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '人工',
|
||||||
|
stdCost: 32100, stdPct: 0.139,
|
||||||
|
purchaseCost: 32100, purchasePct: 0.139,
|
||||||
|
usageCost: 31400, usagePct: 0.136,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: -700, usageVsStdPct: -0.003,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '其他费用',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: '费用',
|
||||||
|
stdCost: 28800, stdPct: 0,
|
||||||
|
purchaseCost: 28800, purchasePct: 0,
|
||||||
|
usageCost: 28800, usagePct: 0,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: 0, usageVsStdPct: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// ── 批量生成的订单 (ORD009–ORD038) ───────────────────────────────────
|
||||||
|
genOrder({ id:'ORD009', orderNo:'F02-XM09-2506010033', customer:'Neewer Global', salesperson:'吴海艳', channel:'外销-B2B', closedDate:'2026-05-12', productCategory:'影棚幕布', status:'closed', riskLevel:'normal', region:'北美区', tags:['幕布','外销稳定'], submittedAt:'2026-05-28', qty:7200, unitPrice:42.5, stdMat:168000, stdLabor:48600, stdOH:42000, purMatAdj:-0.03, useMatAdj:-0.01, useLaborAdj:-0.04, mgmt:36000 }),
|
||||||
|
genOrder({ id:'ORD010', orderNo:'F03-SH10-2505250019', customer:'威克影像', salesperson:'李晓婷', channel:'内销-直销', closedDate:'2026-04-20', productCategory:'摄影幕布', status:'closed', riskLevel:'warning', region:'华东区', tags:['内销','毛利偏低'], submittedAt:'2026-05-26', qty:4800, unitPrice:38.6, stdMat:102000, stdLabor:32400, stdOH:28800, purMatAdj:-0.02, useMatAdj:0.05, useLaborAdj:0.03, mgmt:24000, note:'领用成本超标,材料损耗率偏高。' }),
|
||||||
|
genOrder({ id:'ORD011', orderNo:'F01-EU11-2505180042', customer:'Manfrotto EU', salesperson:'陈建伟', channel:'外销-B2B', closedDate:'2026-04-30', productCategory:'便携背景架', status:'closed', riskLevel:'normal', region:'欧洲区', tags:['架类','高毛利'], submittedAt:'2026-05-25', qty:3600, unitPrice:95.0, stdMat:178000, stdLabor:52000, stdOH:45000, purMatAdj:-0.04, useMatAdj:-0.02, useLaborAdj:-0.03, mgmt:42000, matSplit:[0.45, 0.55] }),
|
||||||
|
genOrder({ id:'ORD012', orderNo:'F02-US12-2505120066', customer:'B&H Photo', salesperson:'张明华', channel:'外销-B2C', closedDate:'2026-05-20', productCategory:'摄影幕布套装', status:'shipped', riskLevel:'normal', region:'北美区', tags:['套装','电商'], submittedAt:'2026-05-24', qty:12000, unitPrice:32.8, stdMat:218000, stdLabor:62000, stdOH:55000, purMatAdj:-0.02, useMatAdj:0.01, useLaborAdj:-0.02, mgmt:48000 }),
|
||||||
|
genOrder({ id:'ORD013', orderNo:'F01-CN13-2505080035', customer:'深圳影视科技', salesperson:'李晓婷', channel:'内销-直销', closedDate:'2026-03-15', productCategory:'直播背景墙', status:'closed', riskLevel:'normal', region:'华南区', tags:['直播','高毛利'], submittedAt:'2026-05-22', qty:2400, unitPrice:145.0, stdMat:168000, stdLabor:48000, stdOH:42000, purMatAdj:-0.03, useMatAdj:-0.02, useLaborAdj:-0.05, mgmt:38000 }),
|
||||||
|
genOrder({ id:'ORD014', orderNo:'F03-JP14-2504280018', customer:'Yodobashi', salesperson:'陈建伟', channel:'外销-B2B', closedDate:'2026-04-05', productCategory:'无缝背景纸', status:'closed', riskLevel:'normal', region:'日本区', tags:['纸类','稳定'], submittedAt:'2026-05-20', qty:8400, unitPrice:28.5, stdMat:128000, stdLabor:38600, stdOH:33000, purMatAdj:-0.02, useMatAdj:-0.01, useLaborAdj:-0.02, mgmt:28000 }),
|
||||||
|
genOrder({ id:'ORD015', orderNo:'F02-KR15-2504200027', customer:'Samsung C&T', salesperson:'吴海艳', channel:'外销-B2B', closedDate:'2026-04-18', productCategory:'幕布定制款', status:'closed', riskLevel:'warning', region:'韩国区', tags:['定制','价格压力'], submittedAt:'2026-05-18', qty:5600, unitPrice:36.0, stdMat:112000, stdLabor:35000, stdOH:30000, purMatAdj:-0.01, useMatAdj:0.04, useLaborAdj:0.02, mgmt:26000, note:'定制款工艺复杂,领用材料偏差较大。' }),
|
||||||
|
genOrder({ id:'ORD016', orderNo:'F01-AU16-2504150044', customer:'Aputure AU', salesperson:'张明华', channel:'外销-B2B', closedDate:'2026-03-28', productCategory:'便携背景架', status:'closed', riskLevel:'normal', region:'澳洲区', tags:['架类','澳洲'], submittedAt:'2026-05-16', qty:1800, unitPrice:108.0, stdMat:98000, stdLabor:32000, stdOH:28000, purMatAdj:-0.03, useMatAdj:-0.01, useLaborAdj:-0.02, mgmt:22000, matSplit:[0.4, 0.6] }),
|
||||||
|
genOrder({ id:'ORD017', orderNo:'F02-CN17-2504100058', customer:'杰创智能', salesperson:'李晓婷', channel:'内销-代理', closedDate:'', productCategory:'智能幕布系统', status:'production', riskLevel:'normal', region:'华东区', tags:['智能','在产'], submittedAt:'2026-05-14', qty:960, unitPrice:286.0, stdMat:128000, stdLabor:42000, stdOH:38000, purMatAdj:-0.02, useMatAdj:0.01, useLaborAdj:0.0, mgmt:32000 }),
|
||||||
|
genOrder({ id:'ORD018', orderNo:'F03-DE18-2504050031', customer:'Walimex DE', salesperson:'陈建伟', channel:'外销-B2B', closedDate:'2026-03-20', productCategory:'影棚幕布', status:'closed', riskLevel:'normal', region:'欧洲区', tags:['幕布','德国'], submittedAt:'2026-05-12', qty:6400, unitPrice:44.2, stdMat:152000, stdLabor:45000, stdOH:40000, purMatAdj:-0.03, useMatAdj:-0.02, useLaborAdj:-0.03, mgmt:34000 }),
|
||||||
|
genOrder({ id:'ORD019', orderNo:'F01-HK19-2503280022', customer:'Nanlite HK', salesperson:'吴海艳', channel:'外销-B2B', closedDate:'2026-03-10', productCategory:'绿幕套装', status:'closed', riskLevel:'danger', region:'港澳区', tags:['绿幕','亏损风险'], submittedAt:'2026-05-10', qty:8800, unitPrice:30.5, stdMat:156000, stdLabor:42000, stdOH:38000, purMatAdj:0.02, useMatAdj:0.08, useLaborAdj:0.05, mgmt:32000, note:'低报价+领用超标严重,三种成本口径均亏损。' }),
|
||||||
|
genOrder({ id:'ORD020', orderNo:'F02-US20-2503220045', customer:'Adorama', salesperson:'张明华', channel:'外销-B2C', closedDate:'2026-05-25', productCategory:'摄影幕布', status:'shipped', riskLevel:'normal', region:'北美区', tags:['幕布','电商'], submittedAt:'2026-05-08', qty:9600, unitPrice:35.6, stdMat:188000, stdLabor:54000, stdOH:48000, purMatAdj:-0.03, useMatAdj:-0.01, useLaborAdj:-0.03, mgmt:40000 }),
|
||||||
|
genOrder({ id:'ORD021', orderNo:'F03-CN21-2503150067', customer:'成都影视基地', salesperson:'李晓婷', channel:'内销-直销', closedDate:'2026-04-22', productCategory:'直播背景墙', status:'closed', riskLevel:'warning', region:'西南区', tags:['直播','毛利偏低'], submittedAt:'2026-05-06', qty:1600, unitPrice:158.0, stdMat:128000, stdLabor:38000, stdOH:34000, purMatAdj:0.01, useMatAdj:0.06, useLaborAdj:0.04, mgmt:30000, note:'客户报价偏低,领用成本偏差明显。' }),
|
||||||
|
genOrder({ id:'ORD022', orderNo:'F01-UK22-2503100029', customer:'Lastolite UK', salesperson:'陈建伟', channel:'外销-B2B', closedDate:'2026-04-08', productCategory:'折叠背景板', status:'closed', riskLevel:'normal', region:'欧洲区', tags:['板类','英国'], submittedAt:'2026-05-04', qty:4200, unitPrice:68.5, stdMat:148000, stdLabor:44000, stdOH:38000, purMatAdj:-0.04, useMatAdj:-0.02, useLaborAdj:-0.03, mgmt:35000, matSplit:[0.5, 0.5] }),
|
||||||
|
genOrder({ id:'ORD023', orderNo:'F02-TW23-2503050038', customer:'台北影视器材', salesperson:'吴海艳', channel:'外销-B2B', closedDate:'2026-03-25', productCategory:'无缝背景纸', status:'closed', riskLevel:'normal', region:'台湾区', tags:['纸类','台湾'], submittedAt:'2026-05-02', qty:11200, unitPrice:22.8, stdMat:138000, stdLabor:40000, stdOH:35000, purMatAdj:-0.02, useMatAdj:-0.01, useLaborAdj:-0.02, mgmt:30000 }),
|
||||||
|
genOrder({ id:'ORD024', orderNo:'F03-CN24-2502280055', customer:'杭州直播产业园', salesperson:'李晓婷', channel:'内销-直销', closedDate:'', productCategory:'智能幕布系统', status:'production', riskLevel:'warning', region:'华东区', tags:['智能','在产','监控'], submittedAt:'2026-04-30', qty:720, unitPrice:320.0, stdMat:108000, stdLabor:36000, stdOH:32000, purMatAdj:0.02, useMatAdj:0.05, useLaborAdj:0.03, mgmt:28000, note:'高单价智能产品,生产中需关注材料损耗。' }),
|
||||||
|
genOrder({ id:'ORD025', orderNo:'F01-IN25-2502200012', customer:'Digitek India', salesperson:'张明华', channel:'外销-B2B', closedDate:'2026-03-05', productCategory:'摄影幕布', status:'closed', riskLevel:'danger', region:'南亚区', tags:['印度','价格战'], submittedAt:'2026-04-28', qty:16000, unitPrice:18.5, stdMat:168000, stdLabor:48000, stdOH:42000, purMatAdj:0.03, useMatAdj:0.10, useLaborAdj:0.06, mgmt:35000, note:'印度市场低价策略导致严重亏损,建议重新评估报价。' }),
|
||||||
|
genOrder({ id:'ORD026', orderNo:'F02-FR26-2502150028', customer:'Colorama FR', salesperson:'陈建伟', channel:'外销-B2B', closedDate:'2026-02-28', productCategory:'无缝背景纸', status:'closed', riskLevel:'normal', region:'欧洲区', tags:['纸类','法国','高毛利'], submittedAt:'2026-04-26', qty:6800, unitPrice:55.2, stdMat:192000, stdLabor:56000, stdOH:48000, purMatAdj:-0.04, useMatAdj:-0.03, useLaborAdj:-0.04, mgmt:42000 }),
|
||||||
|
genOrder({ id:'ORD027', orderNo:'F03-CN27-2502100041', customer:'上海媒体集团', salesperson:'李晓婷', channel:'内销-代理', closedDate:'2026-03-18', productCategory:'直播背景墙', status:'closed', riskLevel:'normal', region:'华东区', tags:['直播','媒体'], submittedAt:'2026-04-24', qty:3200, unitPrice:138.0, stdMat:218000, stdLabor:62000, stdOH:55000, purMatAdj:-0.02, useMatAdj:-0.01, useLaborAdj:-0.02, mgmt:48000 }),
|
||||||
|
genOrder({ id:'ORD028', orderNo:'F01-SG28-2502050015', customer:'Courts SG', salesperson:'吴海艳', channel:'外销-B2B', closedDate:'2026-02-20', productCategory:'影棚幕布', status:'closed', riskLevel:'normal', region:'东南亚区', tags:['幕布','新加坡'], submittedAt:'2026-04-22', qty:5400, unitPrice:40.0, stdMat:118000, stdLabor:36000, stdOH:32000, purMatAdj:-0.03, useMatAdj:-0.02, useLaborAdj:-0.03, mgmt:26000 }),
|
||||||
|
genOrder({ id:'ORD029', orderNo:'F02-CN29-2501280033', customer:'广州影视城', salesperson:'李晓婷', channel:'内销-直销', closedDate:'', productCategory:'幕布定制款', status:'production', riskLevel:'normal', region:'华南区', tags:['定制','在产'], submittedAt:'2026-04-20', qty:3800, unitPrice:52.0, stdMat:108000, stdLabor:34000, stdOH:30000, purMatAdj:-0.01, useMatAdj:0.02, useLaborAdj:0.01, mgmt:24000 }),
|
||||||
|
genOrder({ id:'ORD030', orderNo:'F03-CA30-2501220048', customer:'Vistek Canada', salesperson:'张明华', channel:'外销-B2B', closedDate:'2026-02-10', productCategory:'影棚幕布', status:'closed', riskLevel:'normal', region:'北美区', tags:['幕布','加拿大'], submittedAt:'2026-04-18', qty:4200, unitPrice:48.0, stdMat:108000, stdLabor:33000, stdOH:28000, purMatAdj:-0.02, useMatAdj:-0.01, useLaborAdj:-0.02, mgmt:24000 }),
|
||||||
|
genOrder({ id:'ORD031', orderNo:'F01-CN31-2501150062', customer:'北京视觉科技', salesperson:'李晓婷', channel:'内销-直销', closedDate:'2026-02-25', productCategory:'智能幕布系统', status:'closed', riskLevel:'normal', region:'华北区', tags:['智能','内销'], submittedAt:'2026-04-16', qty:1200, unitPrice:268.0, stdMat:148000, stdLabor:46000, stdOH:40000, purMatAdj:-0.03, useMatAdj:-0.01, useLaborAdj:-0.02, mgmt:36000 }),
|
||||||
|
genOrder({ id:'ORD032', orderNo:'F02-MY32-2501100026', customer:'Godox MY', salesperson:'陈建伟', channel:'外销-B2B', closedDate:'2026-01-28', productCategory:'摄影幕布套装', status:'closed', riskLevel:'warning', region:'东南亚区', tags:['套装','马来西亚'], submittedAt:'2026-04-14', qty:14400, unitPrice:25.6, stdMat:208000, stdLabor:58000, stdOH:52000, purMatAdj:0.01, useMatAdj:0.04, useLaborAdj:0.02, mgmt:44000, note:'批量大但单价低,领用偏差导致毛利空间被压缩。' }),
|
||||||
|
genOrder({ id:'ORD033', orderNo:'F03-CN33-2501050039', customer:'武汉光谷创意园', salesperson:'李晓婷', channel:'内销-代理', closedDate:'', productCategory:'直播背景墙', status:'production', riskLevel:'danger', region:'华中区', tags:['直播','在产','高风险'], submittedAt:'2026-04-12', qty:2800, unitPrice:125.0, stdMat:198000, stdLabor:56000, stdOH:48000, purMatAdj:0.04, useMatAdj:0.12, useLaborAdj:0.08, mgmt:42000, note:'在产订单已出现严重领料超标,预计亏损。' }),
|
||||||
|
genOrder({ id:'ORD034', orderNo:'F01-TH34-2412280017', customer:'JIB Thailand', salesperson:'吴海艳', channel:'外销-B2B', closedDate:'2026-01-15', productCategory:'影棚幕布', status:'closed', riskLevel:'normal', region:'东南亚区', tags:['幕布','泰国'], submittedAt:'2026-04-10', qty:7800, unitPrice:36.8, stdMat:156000, stdLabor:45000, stdOH:40000, purMatAdj:-0.03, useMatAdj:-0.02, useLaborAdj:-0.03, mgmt:34000 }),
|
||||||
|
genOrder({ id:'ORD035', orderNo:'F02-CN35-2412220043', customer:'东莞影视设备', salesperson:'李晓婷', channel:'内销-直销', closedDate:'2026-01-20', productCategory:'摄影幕布', status:'closed', riskLevel:'danger', region:'华南区', tags:['内销','亏损'], submittedAt:'2026-04-08', qty:6200, unitPrice:29.8, stdMat:108000, stdLabor:32000, stdOH:28000, purMatAdj:0.03, useMatAdj:0.09, useLaborAdj:0.06, mgmt:24000, note:'报价过低叠加领用超标,亏损严重。' }),
|
||||||
|
genOrder({ id:'ORD036', orderNo:'F03-BR36-2412150028', customer:'Greika Brazil', salesperson:'张明华', channel:'外销-B2B', closedDate:'2026-01-08', productCategory:'绿幕套装', status:'closed', riskLevel:'warning', region:'南美区', tags:['绿幕','巴西','偏差监控'], submittedAt:'2026-04-06', qty:9200, unitPrice:33.5, stdMat:172000, stdLabor:48000, stdOH:42000, purMatAdj:0.01, useMatAdj:0.05, useLaborAdj:0.03, mgmt:36000, note:'运输成本高,领用偏差需持续监控。' }),
|
||||||
|
genOrder({ id:'ORD037', orderNo:'F01-CN37-2412100051', customer:'浙江影视产业园', salesperson:'吴海艳', channel:'内销-代理', closedDate:'', productCategory:'影棚幕布', status:'production', riskLevel:'normal', region:'华东区', tags:['幕布','在产'], submittedAt:'2026-04-04', qty:5200, unitPrice:45.0, stdMat:128000, stdLabor:38000, stdOH:34000, purMatAdj:-0.02, useMatAdj:0.01, useLaborAdj:0.0, mgmt:28000 }),
|
||||||
|
genOrder({ id:'ORD038', orderNo:'F02-AE38-2412050014', customer:'Noon AE', salesperson:'陈建伟', channel:'外销-B2B', closedDate:'2025-12-28', productCategory:'摄影幕布套装', status:'closed', riskLevel:'normal', region:'中东区', tags:['套装','中东'], submittedAt:'2026-04-02', qty:8000, unitPrice:38.5, stdMat:168000, stdLabor:48000, stdOH:42000, purMatAdj:-0.03, useMatAdj:-0.02, useLaborAdj:-0.03, mgmt:36000 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const statusConfig: Record<OrderStatus, { label: string; color: string }> = {
|
||||||
|
open: { label: '在制中', color: '#00d4ff' },
|
||||||
|
closed: { label: '已结单', color: '#00e599' },
|
||||||
|
production: { label: '生产中', color: '#ffb340' },
|
||||||
|
shipped: { label: '已出货', color: '#7b61ff' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const riskConfig: Record<RiskLevel, { label: string; color: string; bg: string }> = {
|
||||||
|
normal: { label: '正常', color: '#00e599', bg: 'rgba(0,229,153,0.12)' },
|
||||||
|
warning: { label: '预警', color: '#ffb340', bg: 'rgba(255,179,64,0.12)' },
|
||||||
|
danger: { label: '危险', color: '#ff6b6b', bg: 'rgba(255,107,107,0.12)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const channelConfig: Record<Channel, { color: string }> = {
|
||||||
|
'外销-B2B': { color: '#00d4ff' },
|
||||||
|
'外销-B2C': { color: '#4fc3f7' },
|
||||||
|
'内销-直销': { color: '#7b61ff' },
|
||||||
|
'内销-代理': { color: '#ff6b9d' },
|
||||||
|
'电商平台': { color: '#ffb340' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ModuleConfig } from '../registry';
|
||||||
|
|
||||||
|
export const moduleConfig: ModuleConfig = {
|
||||||
|
id: 'cost-analysis',
|
||||||
|
name: 'AI订单成本分析',
|
||||||
|
description: '欣墨科技AI订单成本分析系统,智能分析订单盈利与成本偏差',
|
||||||
|
icon: 'Calculator',
|
||||||
|
path: '/cost-analysis',
|
||||||
|
accentColor: '#00e599',
|
||||||
|
accentColorLight: '#047857',
|
||||||
|
gradient: 'linear-gradient(135deg, #001a33, #003344, #004455)',
|
||||||
|
gradientLight: 'linear-gradient(135deg, #f0fdf4, #dcfce7, #bbf7d0)',
|
||||||
|
tags: ['AI', '成本', '订单'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useRef, type DragEvent } from 'react';
|
||||||
|
import StarBackground from '@/shared/components/StarBackground';
|
||||||
|
import CostHeader from '../components/CostHeader';
|
||||||
|
import LeftOrderPanel from '../components/LeftOrderPanel';
|
||||||
|
import CenterProfitTable from '../components/CenterProfitTable';
|
||||||
|
import RightAIPanel from '../components/RightAIPanel';
|
||||||
|
import BottomCostBar from '../components/BottomCostBar';
|
||||||
|
import { ORDERS, type Order } from '../data/orders';
|
||||||
|
import { useTheme } from '@/shared/context/ThemeContext';
|
||||||
|
|
||||||
|
export default function CostCockpit() {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
||||||
|
const [processingOrder, setProcessingOrder] = useState<Order | null>(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [leftExpanded, setLeftExpanded] = useState(false);
|
||||||
|
const [rightExpanded, setRightExpanded] = useState(false);
|
||||||
|
const [workspaceResetKey, setWorkspaceResetKey] = useState(0);
|
||||||
|
const processingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const startProcessing = (order: Order) => {
|
||||||
|
if (processingTimer.current) clearTimeout(processingTimer.current);
|
||||||
|
setProcessingOrder(order);
|
||||||
|
setIsProcessing(true);
|
||||||
|
setSelectedOrder(null);
|
||||||
|
processingTimer.current = setTimeout(() => {
|
||||||
|
setIsProcessing(false);
|
||||||
|
setProcessingOrder(null);
|
||||||
|
setSelectedOrder(order);
|
||||||
|
}, 3500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetWorkspace = () => {
|
||||||
|
if (processingTimer.current) clearTimeout(processingTimer.current);
|
||||||
|
setSelectedOrder(null);
|
||||||
|
setProcessingOrder(null);
|
||||||
|
setIsProcessing(false);
|
||||||
|
setWorkspaceResetKey(k => k + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCenterDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const orderId = event.dataTransfer.getData('application/x-order-id');
|
||||||
|
const order = ORDERS.find(o => o.id === orderId);
|
||||||
|
if (order) startProcessing(order);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100vw', height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: "-apple-system, 'SF Pro Text', 'PingFang SC', sans-serif",
|
||||||
|
color: tokens.text,
|
||||||
|
background: tokens.bodyBg,
|
||||||
|
}}>
|
||||||
|
{/* 星空背景 */}
|
||||||
|
<StarBackground theme={theme} />
|
||||||
|
|
||||||
|
{/* 内容层 */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
|
||||||
|
{/* 顶部标题栏 */}
|
||||||
|
<CostHeader />
|
||||||
|
|
||||||
|
{/* 留白 */}
|
||||||
|
<div style={{ height: 14, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 中部三栏区域 */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', minHeight: 0, position: 'relative', gap: 0, padding: '0 0' }}>
|
||||||
|
|
||||||
|
{/* 左侧:订单列表面板 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', flexShrink: 0,
|
||||||
|
marginTop: 80, height: '100%',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
width: leftExpanded ? 508 : 296,
|
||||||
|
}}>
|
||||||
|
<LeftOrderPanel
|
||||||
|
selectedId={selectedOrder?.id ?? null}
|
||||||
|
onExpandChange={setLeftExpanded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 间隙 */}
|
||||||
|
<div style={{ width: 14, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 中央:订单盈利能力分析表 */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', position: 'relative', marginTop: 36 }}>
|
||||||
|
{/* 全息边框容器 */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, borderRadius: 16, overflow: 'hidden',
|
||||||
|
border: `1px solid ${tokens.borderAccent}`,
|
||||||
|
boxShadow: theme === 'dark'
|
||||||
|
? '0 0 30px rgba(0,212,255,0.1), 0 0 60px rgba(0,100,200,0.06), inset 0 0 30px rgba(0,30,80,0.15)'
|
||||||
|
: '0 4px 32px rgba(0,60,180,0.12), 0 1px 8px rgba(0,40,120,0.08)',
|
||||||
|
background: tokens.centerBg,
|
||||||
|
backdropFilter: theme === 'dark' ? 'blur(12px)' : 'blur(20px) saturate(1.6)',
|
||||||
|
WebkitBackdropFilter: theme === 'dark' ? 'blur(12px)' : 'blur(20px) saturate(1.6)',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* 中心区域产品背景图 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 0, pointerEvents: 'none',
|
||||||
|
backgroundImage: 'url(/xinmo-bg.png)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
opacity: theme === 'dark' ? 0.12 : 0.05,
|
||||||
|
}} />
|
||||||
|
{/* 四角装饰 */}
|
||||||
|
{['top-left', 'top-right', 'bottom-left', 'bottom-right'].map(pos => {
|
||||||
|
const isTop = pos.includes('top');
|
||||||
|
const isLeft = pos.includes('left');
|
||||||
|
return (
|
||||||
|
<div key={pos} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: isTop ? 8 : undefined,
|
||||||
|
bottom: !isTop ? 8 : undefined,
|
||||||
|
left: isLeft ? 8 : undefined,
|
||||||
|
right: !isLeft ? 8 : undefined,
|
||||||
|
width: 16, height: 16, zIndex: 5, pointerEvents: 'none',
|
||||||
|
borderTop: isTop ? `2px solid ${tokens.borderAccent}` : 'none',
|
||||||
|
borderBottom: !isTop ? `2px solid ${tokens.borderAccent}` : 'none',
|
||||||
|
borderLeft: isLeft ? `2px solid ${tokens.borderAccent}` : 'none',
|
||||||
|
borderRight: !isLeft ? `2px solid ${tokens.borderAccent}` : 'none',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 中央标题栏 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px 10px',
|
||||||
|
borderBottom: `1px solid ${tokens.border}`,
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr auto 1fr',
|
||||||
|
alignItems: 'center', gap: 10, flexShrink: 0,
|
||||||
|
position: 'relative', zIndex: 2,
|
||||||
|
}}>
|
||||||
|
<div />
|
||||||
|
<span
|
||||||
|
onClick={resetWorkspace}
|
||||||
|
title="点击返回初始界面"
|
||||||
|
style={{ color: tokens.titleBarColor, fontSize: 21, fontWeight: 800, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', cursor: 'pointer', textShadow: tokens.titleGlow }}
|
||||||
|
>
|
||||||
|
智能成本分析 Agentic Cost Analysis
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
onClick={resetWorkspace}
|
||||||
|
title="点击恢复初始界面"
|
||||||
|
style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', cursor: 'pointer', height: '100%', flex: 1 }}
|
||||||
|
>
|
||||||
|
{selectedOrder ? (
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 8px', borderRadius: 6,
|
||||||
|
background: tokens.accentBg, border: `1px solid ${tokens.accent}26`,
|
||||||
|
color: tokens.accent, fontSize: 12,
|
||||||
|
}}>
|
||||||
|
{selectedOrder.customer} · {selectedOrder.productCategory} ✕
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{
|
||||||
|
color: `${tokens.accent}33`, fontSize: 12,
|
||||||
|
letterSpacing: '0.06em', userSelect: 'none',
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.color = `${tokens.accent}80`)}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.color = `${tokens.accent}33`)}
|
||||||
|
>
|
||||||
|
点击恢复 ←
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中央内容 */}
|
||||||
|
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 2 }}>
|
||||||
|
<CenterProfitTable
|
||||||
|
order={selectedOrder}
|
||||||
|
resetKey={workspaceResetKey}
|
||||||
|
onDrop={handleCenterDrop}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
processingOrder={processingOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 间隙 */}
|
||||||
|
<div style={{ width: 40, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 右侧:AI分析视图 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', flexShrink: 0,
|
||||||
|
marginTop: 80, height: '100%',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
width: rightExpanded ? 528 : 296,
|
||||||
|
}}>
|
||||||
|
<RightAIPanel
|
||||||
|
selectedOrder={selectedOrder}
|
||||||
|
onExpandChange={setRightExpanded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部留白 */}
|
||||||
|
<div style={{ height: 12, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 底部AI问答区 */}
|
||||||
|
<BottomCostBar selectedOrder={selectedOrder} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { rawMaterials, projectMap } from '../data/materials';
|
||||||
|
import type { Material } from '../data/materials';
|
||||||
|
|
||||||
|
// Light theme (Apple style)
|
||||||
|
const lightCategoryColors: Record<string, string> = {
|
||||||
|
'温控系统': '#0A7AFF',
|
||||||
|
'气路系统': '#7B6CF7',
|
||||||
|
'阀件系统': '#F07830',
|
||||||
|
'结构件': '#28A866',
|
||||||
|
};
|
||||||
|
const lightAgingColors: Record<string, string> = {
|
||||||
|
'60-90': '#28A866',
|
||||||
|
'90-120': '#E08C00',
|
||||||
|
'120-150':'#D94040',
|
||||||
|
};
|
||||||
|
const ROOT_COLOR = '#5E5CE6';
|
||||||
|
|
||||||
|
// Dark / space theme (neon)
|
||||||
|
const darkCategoryColors: Record<string, string> = {
|
||||||
|
'温控系统': '#38b8ff',
|
||||||
|
'气路系统': '#a78bfa',
|
||||||
|
'阀件系统': '#fb923c',
|
||||||
|
'结构件': '#34d399',
|
||||||
|
};
|
||||||
|
const darkAgingColors: Record<string, string> = {
|
||||||
|
'60-90': '#34d399',
|
||||||
|
'90-120': '#fbbf24',
|
||||||
|
'120-150':'#f87171',
|
||||||
|
};
|
||||||
|
const DARK_ROOT_COLOR = '#818cf8';
|
||||||
|
|
||||||
|
interface NodeData {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: 'material' | 'project' | 'category' | 'aging';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
material?: Material;
|
||||||
|
count?: number;
|
||||||
|
agingRange?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EdgeData {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnowledgeGraphProps {
|
||||||
|
onNodeSelect: (node: NodeData | null) => void;
|
||||||
|
filterCategory: string | null;
|
||||||
|
filterAging: string | null;
|
||||||
|
viewMode: 'category' | 'project' | 'aging';
|
||||||
|
darkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGraphData(
|
||||||
|
filterCategory: string | null,
|
||||||
|
filterAging: string | null,
|
||||||
|
viewMode: 'category' | 'project' | 'aging',
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
darkMode: boolean
|
||||||
|
): { nodes: NodeData[]; edges: EdgeData[] } {
|
||||||
|
const catColors = darkMode ? darkCategoryColors : lightCategoryColors;
|
||||||
|
const agColors = darkMode ? darkAgingColors : lightAgingColors;
|
||||||
|
const rootColor = darkMode ? DARK_ROOT_COLOR : ROOT_COLOR;
|
||||||
|
|
||||||
|
const nodes: NodeData[] = [];
|
||||||
|
const edges: EdgeData[] = [];
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
|
||||||
|
let materials = rawMaterials;
|
||||||
|
if (filterCategory) materials = materials.filter(m => m.category === filterCategory);
|
||||||
|
if (filterAging) materials = materials.filter(m => m.aging === filterAging);
|
||||||
|
|
||||||
|
if (viewMode === 'category') {
|
||||||
|
nodes.push({ id: 'root', label: '采购订单', type: 'aging', x: cx, y: cy, size: 36, color: rootColor, count: materials.length });
|
||||||
|
const cats = [...new Set(materials.map(m => m.category))];
|
||||||
|
cats.forEach((cat, ci) => {
|
||||||
|
const angle = (ci / cats.length) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const catR = Math.min(width, height) * 0.27;
|
||||||
|
const catX = cx + catR * Math.cos(angle);
|
||||||
|
const catY = cy + catR * Math.sin(angle);
|
||||||
|
const catId = `cat-${cat}`;
|
||||||
|
const catMats = materials.filter(m => m.category === cat);
|
||||||
|
nodes.push({ id: catId, label: cat, type: 'category', x: catX, y: catY, size: 26, color: catColors[cat] || '#94a3b8', count: catMats.length });
|
||||||
|
edges.push({ id: `root-${catId}`, source: 'root', target: catId });
|
||||||
|
catMats.forEach((mat, mi) => {
|
||||||
|
const spread = Math.min(Math.PI * 1.0, (catMats.length - 1) * 0.28);
|
||||||
|
const matAngle = angle + (catMats.length > 1 ? -spread / 2 + (mi / (catMats.length - 1)) * spread : 0);
|
||||||
|
const matR = Math.min(width, height) * 0.18;
|
||||||
|
nodes.push({ id: mat.id, label: mat.name, type: 'material', x: catX + matR * Math.cos(matAngle), y: catY + matR * Math.sin(matAngle), size: 16, color: catColors[cat] || '#94a3b8', material: mat });
|
||||||
|
edges.push({ id: `${catId}-${mat.id}`, source: catId, target: mat.id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (viewMode === 'project') {
|
||||||
|
nodes.push({ id: 'root', label: '采购订单', type: 'aging', x: cx, y: cy, size: 36, color: rootColor, count: materials.length });
|
||||||
|
const projs = [...new Set(materials.map(m => m.projectId))];
|
||||||
|
projs.forEach((proj, pi) => {
|
||||||
|
const angle = (pi / projs.length) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const projR = Math.min(width, height) * 0.27;
|
||||||
|
const projX = cx + projR * Math.cos(angle);
|
||||||
|
const projY = cy + projR * Math.sin(angle);
|
||||||
|
const projId = `proj-${proj}`;
|
||||||
|
const projMats = materials.filter(m => m.projectId === proj);
|
||||||
|
nodes.push({ id: projId, label: projectMap[proj]?.name || proj, type: 'project', x: projX, y: projY, size: 22, color: darkMode ? '#a78bfa' : '#7B6CF7', count: projMats.length });
|
||||||
|
edges.push({ id: `root-${projId}`, source: 'root', target: projId });
|
||||||
|
projMats.forEach((mat, mi) => {
|
||||||
|
const spread = Math.min(Math.PI * 1.0, (projMats.length - 1) * 0.35);
|
||||||
|
const matAngle = angle + (projMats.length > 1 ? -spread / 2 + (mi / (projMats.length - 1)) * spread : 0);
|
||||||
|
const matR = Math.min(width, height) * 0.17;
|
||||||
|
nodes.push({ id: mat.id, label: mat.name, type: 'material', x: projX + matR * Math.cos(matAngle), y: projY + matR * Math.sin(matAngle), size: 14, color: catColors[mat.category] || '#94a3b8', material: mat });
|
||||||
|
edges.push({ id: `${projId}-${mat.id}`, source: projId, target: mat.id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
nodes.push({ id: 'root', label: '采购订单', type: 'aging', x: cx, y: cy, size: 36, color: rootColor, count: materials.length });
|
||||||
|
['60-90', '90-120', '120-150'].forEach((aging, ai) => {
|
||||||
|
const agingMats = materials.filter(m => m.aging === aging);
|
||||||
|
if (agingMats.length === 0) return;
|
||||||
|
const angle = (ai / 3) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const agR = Math.min(width, height) * 0.27;
|
||||||
|
const agX = cx + agR * Math.cos(angle);
|
||||||
|
const agY = cy + agR * Math.sin(angle);
|
||||||
|
const agId = `aging-${aging}`;
|
||||||
|
nodes.push({ id: agId, label: `账龄 ${aging}天`, type: 'aging', x: agX, y: agY, size: 24, color: agColors[aging], count: agingMats.length, agingRange: aging });
|
||||||
|
edges.push({ id: `root-${agId}`, source: 'root', target: agId });
|
||||||
|
agingMats.forEach((mat, mi) => {
|
||||||
|
const spread = Math.min(Math.PI * 1.1, (agingMats.length - 1) * 0.22);
|
||||||
|
const matAngle = angle + (agingMats.length > 1 ? -spread / 2 + (mi / (agingMats.length - 1)) * spread : 0);
|
||||||
|
const matR = Math.min(width, height) * 0.18;
|
||||||
|
nodes.push({ id: mat.id, label: mat.name, type: 'material', x: agX + matR * Math.cos(matAngle), y: agY + matR * Math.sin(matAngle), size: 14, color: agColors[aging], material: mat });
|
||||||
|
edges.push({ id: `${agId}-${mat.id}`, source: agId, target: mat.id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KnowledgeGraph({ onNodeSelect, filterCategory, filterAging, viewMode, darkMode = false }: KnowledgeGraphProps) {
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ width: 900, height: 620 });
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||||
|
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
|
||||||
|
const isPanning = useRef(false);
|
||||||
|
const lastMouse = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ro = new ResizeObserver(entries => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width, height } = entry.contentRect;
|
||||||
|
setDimensions({ width: Math.max(600, width), height: Math.max(400, height) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (containerRef.current) ro.observe(containerRef.current);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { nodes, edges } = buildGraphData(filterCategory, filterAging, viewMode, dimensions.width, dimensions.height, darkMode);
|
||||||
|
const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n]));
|
||||||
|
|
||||||
|
// 当前主题的 aging 颜色(节点角标用)
|
||||||
|
const agColors = darkMode ? darkAgingColors : lightAgingColors;
|
||||||
|
|
||||||
|
const handleNodeClick = useCallback((node: NodeData) => {
|
||||||
|
const next = selectedId === node.id ? null : node.id;
|
||||||
|
setSelectedId(next);
|
||||||
|
onNodeSelect(next ? node : null);
|
||||||
|
}, [selectedId, onNodeSelect]);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if ((e.target as Element).closest('.graph-node')) return;
|
||||||
|
isPanning.current = true;
|
||||||
|
lastMouse.current = { x: e.clientX, y: e.clientY };
|
||||||
|
};
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isPanning.current) return;
|
||||||
|
setTransform(t => ({ ...t, x: t.x + e.clientX - lastMouse.current.x, y: t.y + e.clientY - lastMouse.current.y }));
|
||||||
|
lastMouse.current = { x: e.clientX, y: e.clientY };
|
||||||
|
};
|
||||||
|
const handleMouseUp = () => { isPanning.current = false; };
|
||||||
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setTransform(t => ({ ...t, scale: Math.min(2.5, Math.max(0.4, t.scale * (e.deltaY > 0 ? 0.9 : 1.1))) }));
|
||||||
|
};
|
||||||
|
const resetView = () => setTransform({ x: 0, y: 0, scale: 1 });
|
||||||
|
|
||||||
|
const active = selectedId || hoveredId;
|
||||||
|
|
||||||
|
const getEdgeOpacity = (edge: EdgeData) => {
|
||||||
|
if (!active) return 0.28;
|
||||||
|
if (edge.source === active || edge.target === active) return 1;
|
||||||
|
return 0.06;
|
||||||
|
};
|
||||||
|
const getNodeOpacity = (node: NodeData) => {
|
||||||
|
if (!active) return 1;
|
||||||
|
if (node.id === active) return 1;
|
||||||
|
if (edges.some(e => (e.source === active && e.target === node.id) || (e.target === active && e.source === node.id))) return 0.9;
|
||||||
|
if (node.id === 'root') return 0.5;
|
||||||
|
return 0.18;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 主题相关样式变量 ──────────────────────────────────────
|
||||||
|
const bgColor = darkMode ? '#010f28' : '#f5f5f7';
|
||||||
|
const dotColor = darkMode ? 'rgba(0,212,255,0.12)' : '#cdd0d8';
|
||||||
|
const edgeBaseColor = darkMode ? 'rgba(100,180,255,0.35)' : 'hsl(220,10%,72%)';
|
||||||
|
const nodeFill = darkMode ? 'rgba(1,20,55,0.85)' : 'white';
|
||||||
|
const labelColor = darkMode ? '#c8e8ff' : '#3a3a3c';
|
||||||
|
const statsNumColor = darkMode ? '#00d4ff' : '#1c1c1e';
|
||||||
|
const statsTxtColor = darkMode ? '#4a7fa8' : '#6d6d72';
|
||||||
|
const statsDivColor = darkMode ? 'rgba(0,212,255,0.2)' : '#d1d1d6';
|
||||||
|
const legendBg = darkMode ? 'rgba(0,20,60,0.7)' : 'rgba(255,255,255,0.88)';
|
||||||
|
const legendBorder = darkMode ? '1px solid rgba(0,212,255,0.2)' : '1px solid rgba(255,255,255,0.7)';
|
||||||
|
const legendTitle = darkMode ? '#a8d8ff' : '#3a3a3c';
|
||||||
|
const legendText = darkMode ? '#6aaccc' : '#6d6d72';
|
||||||
|
const zoomBg = darkMode ? 'rgba(0,20,60,0.75)' : 'rgba(255,255,255,0.88)';
|
||||||
|
const zoomBorder = darkMode ? '1px solid rgba(0,212,255,0.2)' : '1px solid rgba(255,255,255,0.7)';
|
||||||
|
const zoomColor = darkMode ? '#a8d8ff' : '#3a3a3c';
|
||||||
|
const zoomHoverBg = darkMode ? 'rgba(0,212,255,0.1)' : '#f2f2f7';
|
||||||
|
|
||||||
|
const legendItems = darkMode
|
||||||
|
? [
|
||||||
|
{ color: DARK_ROOT_COLOR, label: '采购订单' },
|
||||||
|
{ color: darkCategoryColors['温控系统'], label: '温控系统' },
|
||||||
|
{ color: darkCategoryColors['气路系统'], label: '气路系统' },
|
||||||
|
{ color: darkCategoryColors['阀件系统'], label: '阀件系统' },
|
||||||
|
{ color: darkCategoryColors['结构件'], label: '结构件' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ color: ROOT_COLOR, label: '采购订单' },
|
||||||
|
{ color: lightCategoryColors['温控系统'], label: '温控系统' },
|
||||||
|
{ color: lightCategoryColors['气路系统'], label: '气路系统' },
|
||||||
|
{ color: lightCategoryColors['阀件系统'], label: '阀件系统' },
|
||||||
|
{ color: lightCategoryColors['结构件'], label: '结构件' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full h-full rounded-xl overflow-hidden"
|
||||||
|
style={{ cursor: isPanning.current ? 'grabbing' : 'grab', backgroundColor: bgColor }}>
|
||||||
|
|
||||||
|
{/* Zoom controls */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 16, right: 16, zIndex: 10,
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 4,
|
||||||
|
borderRadius: 14, overflow: 'hidden',
|
||||||
|
backgroundColor: zoomBg,
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
border: zoomBorder,
|
||||||
|
boxShadow: darkMode ? '0 0 16px rgba(0,212,255,0.12)' : '0 2px 12px rgba(0,0,0,0.10)',
|
||||||
|
}}>
|
||||||
|
{[
|
||||||
|
{ label: '+', action: () => setTransform(t => ({ ...t, scale: Math.min(2.5, t.scale * 1.2) })) },
|
||||||
|
{ label: '−', action: () => setTransform(t => ({ ...t, scale: Math.max(0.4, t.scale * 0.8) })) },
|
||||||
|
{ label: '⊙', action: resetView },
|
||||||
|
].map(btn => (
|
||||||
|
<button key={btn.label} onClick={btn.action}
|
||||||
|
style={{ width: 42, height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, fontWeight: 600, color: zoomColor, backgroundColor: 'transparent', border: 'none', cursor: 'pointer', transition: 'background 0.15s' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.backgroundColor = zoomHoverBg)}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'transparent')}>
|
||||||
|
{btn.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 16, left: 16, zIndex: 10,
|
||||||
|
padding: '14px 16px', minWidth: 150,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: legendBg,
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
border: legendBorder,
|
||||||
|
boxShadow: darkMode ? '0 0 20px rgba(0,212,255,0.08)' : '0 2px 8px rgba(0,0,0,0.07)',
|
||||||
|
}}>
|
||||||
|
<p style={{ color: legendTitle, fontSize: 13, fontWeight: 600, marginBottom: 10, letterSpacing: '-0.01em' }}>图例</p>
|
||||||
|
{legendItems.map(item => (
|
||||||
|
<div key={item.label} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 7 }}>
|
||||||
|
<div style={{ width: 10, height: 10, borderRadius: '50%', backgroundColor: item.color, flexShrink: 0,
|
||||||
|
boxShadow: darkMode ? `0 0 6px ${item.color}` : 'none' }} />
|
||||||
|
<span style={{ color: legendText, fontSize: 13, letterSpacing: '-0.01em' }}>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg ref={svgRef} width={dimensions.width} height={dimensions.height} className="w-full h-full"
|
||||||
|
onMouseDown={handleMouseDown} onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onWheel={handleWheel}>
|
||||||
|
<defs>
|
||||||
|
<pattern id="kg-dot-grid" width="36" height="36" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="18" cy="18" r="1" fill={dotColor} />
|
||||||
|
</pattern>
|
||||||
|
<filter id="kg-shadow-node">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="rgba(0,0,0,0.10)" />
|
||||||
|
</filter>
|
||||||
|
<filter id="kg-shadow-selected">
|
||||||
|
<feDropShadow dx="0" dy="3" stdDeviation="6" floodColor="rgba(0,0,0,0.15)" />
|
||||||
|
</filter>
|
||||||
|
{darkMode && (
|
||||||
|
<filter id="kg-glow-node">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
|
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
|
||||||
|
</filter>
|
||||||
|
)}
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Background */}
|
||||||
|
<rect width={dimensions.width} height={dimensions.height} fill={bgColor} />
|
||||||
|
<rect width={dimensions.width} height={dimensions.height} fill="url(#kg-dot-grid)" opacity={darkMode ? 1 : 0.8} />
|
||||||
|
|
||||||
|
<g transform={`translate(${transform.x},${transform.y}) scale(${transform.scale})`}>
|
||||||
|
{/* Edges */}
|
||||||
|
{edges.map(edge => {
|
||||||
|
const src = nodeMap[edge.source];
|
||||||
|
const tgt = nodeMap[edge.target];
|
||||||
|
if (!src || !tgt) return null;
|
||||||
|
const isAct = active && (edge.source === active || edge.target === active);
|
||||||
|
return (
|
||||||
|
<line key={edge.id}
|
||||||
|
x1={src.x} y1={src.y} x2={tgt.x} y2={tgt.y}
|
||||||
|
stroke={isAct ? src.color : edgeBaseColor}
|
||||||
|
strokeWidth={isAct ? 2 : 1.2}
|
||||||
|
strokeOpacity={getEdgeOpacity(edge)}
|
||||||
|
strokeDasharray={tgt.type === 'material' ? '5,4' : undefined}
|
||||||
|
style={{ transition: 'all 0.2s ease' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
{nodes.map(node => {
|
||||||
|
const isSel = selectedId === node.id;
|
||||||
|
const isHov = hoveredId === node.id;
|
||||||
|
const sc = isSel ? 1.22 : isHov ? 1.08 : 1;
|
||||||
|
const r = node.size * sc;
|
||||||
|
return (
|
||||||
|
<g key={node.id} className="graph-node"
|
||||||
|
transform={`translate(${node.x},${node.y})`}
|
||||||
|
onClick={() => handleNodeClick(node)}
|
||||||
|
onMouseEnter={() => setHoveredId(node.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
style={{ opacity: getNodeOpacity(node), transition: 'opacity 0.2s ease' }}>
|
||||||
|
|
||||||
|
{/* Selected ring */}
|
||||||
|
{isSel && (
|
||||||
|
<circle r={r + 9} fill="none"
|
||||||
|
stroke={node.color} strokeWidth={2}
|
||||||
|
strokeOpacity={darkMode ? 0.5 : 0.22}
|
||||||
|
strokeDasharray="3,3" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Glow halo */}
|
||||||
|
<circle r={r * 1.45} fill={node.color}
|
||||||
|
opacity={isSel ? (darkMode ? 0.2 : 0.12) : (darkMode ? 0.1 : 0.06)}
|
||||||
|
style={{ transition: 'all 0.2s ease' }} />
|
||||||
|
|
||||||
|
{/* Node base fill */}
|
||||||
|
<circle r={r} fill={nodeFill}
|
||||||
|
filter={isSel ? 'url(#kg-shadow-selected)' : 'url(#kg-shadow-node)'}
|
||||||
|
style={{ transition: 'all 0.2s ease' }} />
|
||||||
|
|
||||||
|
{/* Colored fill */}
|
||||||
|
<circle r={r} fill={node.color}
|
||||||
|
fillOpacity={node.type === 'material' ? (darkMode ? 0.75 : 0.82) : (darkMode ? 0.85 : 0.9)}
|
||||||
|
stroke={darkMode ? 'rgba(255,255,255,0.15)' : 'white'}
|
||||||
|
strokeWidth={isSel ? 2.5 : isHov ? 1.5 : 1}
|
||||||
|
strokeOpacity={0.9}
|
||||||
|
filter={darkMode && (isSel || isHov) ? 'url(#kg-glow-node)' : undefined}
|
||||||
|
style={{ transition: 'all 0.2s ease' }} />
|
||||||
|
|
||||||
|
{/* Count label */}
|
||||||
|
{node.type !== 'material' && node.count !== undefined && (
|
||||||
|
<text textAnchor="middle" dominantBaseline="central"
|
||||||
|
fill="white" fontSize={node.size * 0.60} fontWeight="700"
|
||||||
|
fontFamily="-apple-system, 'SF Pro Display', 'PingFang SC', sans-serif"
|
||||||
|
style={{ pointerEvents: 'none', letterSpacing: '-0.02em' }}>
|
||||||
|
{node.count}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label below node */}
|
||||||
|
<text
|
||||||
|
y={r + (node.type === 'material' ? 14 : 17)}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={isSel ? node.color : labelColor}
|
||||||
|
fontSize={node.type === 'material' ? 12 : 13.5}
|
||||||
|
fontWeight={isSel ? '700' : node.type !== 'material' ? '600' : '500'}
|
||||||
|
fontFamily="-apple-system, 'SF Pro Text', 'PingFang SC', sans-serif"
|
||||||
|
style={{ transition: 'fill 0.2s ease', pointerEvents: 'none', letterSpacing: '-0.01em' }}>
|
||||||
|
{node.label.length > 8 ? node.label.slice(0, 7) + '…' : node.label}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Aging indicator dot */}
|
||||||
|
{node.type === 'material' && node.material && (
|
||||||
|
<circle cx={r * 0.65} cy={-r * 0.65} r={3.5}
|
||||||
|
fill={agColors[node.material.aging] || '#94a3b8'}
|
||||||
|
stroke={darkMode ? 'rgba(0,0,0,0.4)' : 'white'}
|
||||||
|
strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Stats badge */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 16, left: 16, zIndex: 10,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 16px', borderRadius: 12,
|
||||||
|
backgroundColor: darkMode ? 'rgba(0,20,60,0.7)' : 'rgba(255,255,255,0.88)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
border: darkMode ? '1px solid rgba(0,212,255,0.2)' : '1px solid rgba(255,255,255,0.7)',
|
||||||
|
boxShadow: darkMode ? '0 0 16px rgba(0,212,255,0.08)' : '0 2px 8px rgba(0,0,0,0.07)',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: statsNumColor, fontWeight: 600, fontSize: 15 }}>{nodes.filter(n => n.type === 'material').length}</span>
|
||||||
|
<span style={{ color: statsTxtColor, fontSize: 13 }}>个物料节点</span>
|
||||||
|
<span style={{ color: statsDivColor, margin: '0 2px' }}>·</span>
|
||||||
|
<span style={{ color: statsNumColor, fontWeight: 600, fontSize: 15 }}>{edges.length}</span>
|
||||||
|
<span style={{ color: statsTxtColor, fontSize: 13 }}>条关系边</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { rawMaterials } from '../data/materials';
|
||||||
|
import type { Material } from '../data/materials';
|
||||||
|
|
||||||
|
const lightCategoryColors: Record<string, string> = {
|
||||||
|
'温控系统': '#0A7AFF',
|
||||||
|
'气路系统': '#7B6CF7',
|
||||||
|
'阀件系统': '#F07830',
|
||||||
|
'结构件': '#28A866',
|
||||||
|
};
|
||||||
|
const lightAgingColors: Record<string, string> = {
|
||||||
|
'60-90': '#28A866',
|
||||||
|
'90-120': '#E08C00',
|
||||||
|
'120-150':'#D94040',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MaterialListProps {
|
||||||
|
filterCategory: string | null;
|
||||||
|
filterAging: string | null;
|
||||||
|
onSelect: (mat: Material | null) => void;
|
||||||
|
selectedId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MaterialList({ filterCategory, filterAging, onSelect, selectedId }: MaterialListProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
let list = rawMaterials;
|
||||||
|
if (filterCategory) list = list.filter(m => m.category === filterCategory);
|
||||||
|
if (filterAging) list = list.filter(m => m.aging === filterAging);
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
list = list.filter(m => m.name.includes(q) || m.id.toLowerCase().includes(q) || m.spec.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', height:'100%' }}>
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ padding:'0 16px 10px' }}>
|
||||||
|
<input value={search} onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="搜索料号、名称或规格…"
|
||||||
|
style={{
|
||||||
|
width:'100%', padding:'10px 14px', borderRadius:12,
|
||||||
|
border:'1px solid #e8e8ed', backgroundColor:'#f5f5f7',
|
||||||
|
fontSize:14, color:'#1c1c1e', outline:'none',
|
||||||
|
boxSizing:'border-box', letterSpacing:'-0.01em', transition:'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
onFocus={e => { e.currentTarget.style.borderColor='rgba(10,122,255,0.5)'; e.currentTarget.style.backgroundColor='#ffffff'; }}
|
||||||
|
onBlur={e => { e.currentTarget.style.borderColor='#e8e8ed'; e.currentTarget.style.backgroundColor='#f5f5f7'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Count */}
|
||||||
|
<div style={{ padding:'0 16px 10px' }}>
|
||||||
|
<p style={{ fontSize:13, color:'#8e8e93', letterSpacing:'-0.01em' }}>
|
||||||
|
共 <strong style={{ color:'#1c1c1e', fontWeight:600 }}>{list.length}</strong> 条物料
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* List */}
|
||||||
|
<div style={{ flex:1, overflowY:'auto', padding:'0 16px 16px', display:'flex', flexDirection:'column', gap:8 }}>
|
||||||
|
{list.map(mat => {
|
||||||
|
const agColor = lightAgingColors[mat.aging] || '#94a3b8';
|
||||||
|
const catColor = lightCategoryColors[mat.category] || '#94a3b8';
|
||||||
|
const isSel = selectedId === mat.id;
|
||||||
|
return (
|
||||||
|
<button key={mat.id} onClick={() => onSelect(isSel ? null : mat)}
|
||||||
|
style={{
|
||||||
|
padding:'12px 14px', borderRadius:12, textAlign:'left', cursor:'pointer',
|
||||||
|
border: isSel ? `1.5px solid ${catColor}50` : '1px solid #e8e8ed',
|
||||||
|
backgroundColor: isSel ? `${catColor}0e` : '#ffffff',
|
||||||
|
transition:'all 0.18s ease', outline:'none',
|
||||||
|
boxShadow: isSel ? `0 2px 12px ${catColor}18` : '0 1px 4px rgba(0,0,0,0.05)',
|
||||||
|
color:'#1c1c1e',
|
||||||
|
}}>
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap:10 }}>
|
||||||
|
<div style={{ minWidth:0 }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:7, marginBottom:4 }}>
|
||||||
|
<div style={{ width:7, height:7, borderRadius:'50%', backgroundColor:catColor, flexShrink:0 }} />
|
||||||
|
<span style={{ fontSize:14, fontWeight:600, color:'#1c1c1e', letterSpacing:'-0.02em', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{mat.name}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize:11, fontFamily:'SF Mono,Menlo,Monaco,monospace', color:'#8e8e93', letterSpacing:'0.01em' }}>{mat.id}</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ flexShrink:0, display:'flex', flexDirection:'column', alignItems:'flex-end', gap:5 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize:12, fontWeight:500, padding:'3px 9px', borderRadius:20,
|
||||||
|
backgroundColor:`${agColor}14`, color:agColor, border:`1px solid ${agColor}35`,
|
||||||
|
letterSpacing:'-0.01em', whiteSpace:'nowrap',
|
||||||
|
}}>{mat.aging}天</span>
|
||||||
|
<span style={{ fontSize:12, color:'#8e8e93' }}>×{mat.qty}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize:12, color:'#8e8e93', marginTop:7, lineHeight:1.5, letterSpacing:'-0.01em', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||||||
|
{mat.spec}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{list.length === 0 && (
|
||||||
|
<div style={{ textAlign:'center', padding:'40px 0', color:'#8e8e93', fontSize:14 }}>未找到匹配物料</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { projectMap, prefixLabels } from '../data/materials';
|
||||||
|
import type { Material } from '../data/materials';
|
||||||
|
|
||||||
|
const lightCategoryColors: Record<string, string> = {
|
||||||
|
'温控系统': '#0A7AFF',
|
||||||
|
'气路系统': '#7B6CF7',
|
||||||
|
'阀件系统': '#F07830',
|
||||||
|
'结构件': '#28A866',
|
||||||
|
};
|
||||||
|
const lightAgingColors: Record<string, string> = {
|
||||||
|
'60-90': '#28A866',
|
||||||
|
'90-120': '#E08C00',
|
||||||
|
'120-150':'#D94040',
|
||||||
|
};
|
||||||
|
const darkCategoryColors: Record<string, string> = {
|
||||||
|
'温控系统': '#38b8ff',
|
||||||
|
'气路系统': '#a78bfa',
|
||||||
|
'阀件系统': '#fb923c',
|
||||||
|
'结构件': '#34d399',
|
||||||
|
};
|
||||||
|
const darkAgingColors: Record<string, string> = {
|
||||||
|
'60-90': '#34d399',
|
||||||
|
'90-120': '#fbbf24',
|
||||||
|
'120-150':'#f87171',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NodeDetailPanelProps {
|
||||||
|
node: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
color: string;
|
||||||
|
material?: Material;
|
||||||
|
count?: number;
|
||||||
|
agingRange?: string;
|
||||||
|
} | null;
|
||||||
|
onClose: () => void;
|
||||||
|
darkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children, darkMode }: { label: string; children: React.ReactNode; darkMode: boolean }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
|
||||||
|
padding: '10px 0',
|
||||||
|
borderBottom: `1px solid ${darkMode ? 'rgba(0,212,255,0.08)' : '#f2f2f7'}`,
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 13, color: darkMode ? '#4a7fa8' : '#8e8e93', letterSpacing: '-0.01em', flexShrink: 0 }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, color: darkMode ? '#a8d8ff' : '#1c1c1e', letterSpacing: '-0.01em', textAlign: 'right' }}>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NodeDetailPanel({ node, onClose, darkMode = false }: NodeDetailPanelProps) {
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
const catColors = darkMode ? darkCategoryColors : lightCategoryColors;
|
||||||
|
const agColors = darkMode ? darkAgingColors : lightAgingColors;
|
||||||
|
|
||||||
|
const mat = node.material;
|
||||||
|
const catColor = mat ? (catColors[mat.category] || '#94a3b8') : node.color;
|
||||||
|
const agColor = mat ? (agColors[mat.aging] || '#94a3b8') : node.color;
|
||||||
|
|
||||||
|
const typeLabel: Record<string, string> = {
|
||||||
|
material: '物料节点', project: '项目节点', category: '分类节点', aging: '账龄节点',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 深色主题变量 ──────────────────────────────
|
||||||
|
const panelBg = darkMode ? 'rgba(0,15,45,0.92)' : '#ffffff';
|
||||||
|
const panelBorder = darkMode ? '1px solid rgba(0,212,255,0.2)' : '1px solid #e5e5ea';
|
||||||
|
const panelShadow = darkMode ? '0 0 30px rgba(0,212,255,0.12)' : '0 4px 28px rgba(0,0,0,0.09)';
|
||||||
|
const headerBorder = darkMode ? 'rgba(0,212,255,0.1)' : '#f2f2f7';
|
||||||
|
const titleColor = darkMode ? '#e8f4ff' : '#1c1c1e';
|
||||||
|
const subtitleColor= darkMode ? '#4a7fa8' : '#8e8e93';
|
||||||
|
const closeBtnBg = darkMode ? 'rgba(0,212,255,0.1)' : '#f2f2f7';
|
||||||
|
const closeBtnColor= darkMode ? '#00d4ff' : '#6d6d72';
|
||||||
|
const specBg = darkMode ? 'rgba(0,30,70,0.6)' : '#f9f9fb';
|
||||||
|
const specBorder = darkMode ? 'rgba(0,212,255,0.12)' : '#e8e8ed';
|
||||||
|
const specText = darkMode ? '#7aadcc' : '#3a3a3c';
|
||||||
|
const sectionLabel = darkMode ? '#3a6080' : '#aeaeb2';
|
||||||
|
const projBg = darkMode ? 'rgba(100,80,200,0.12)' : '#f5f0ff';
|
||||||
|
const projBorder = darkMode ? 'rgba(167,139,250,0.25)' : 'rgba(123,108,247,0.18)';
|
||||||
|
const projTitle = darkMode ? '#a78bfa' : '#7B6CF7';
|
||||||
|
const projDesc = darkMode ? '#4a7fa8' : '#6d6d72';
|
||||||
|
const projId = darkMode ? '#3a6080' : '#aeaeb2';
|
||||||
|
const groupBg = darkMode ? 'rgba(0,30,70,0.5)' : '#f9f9fb';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: panelBg,
|
||||||
|
backdropFilter: darkMode ? 'blur(20px)' : undefined,
|
||||||
|
WebkitBackdropFilter: darkMode ? 'blur(20px)' : undefined,
|
||||||
|
borderRadius: 18, height: '100%', overflowY: 'auto',
|
||||||
|
border: panelBorder,
|
||||||
|
boxShadow: panelShadow,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '18px 18px 14px', borderBottom: `1px solid ${headerBorder}`, flexShrink: 0 }}>
|
||||||
|
{/* 深色模式顶部扫光线 */}
|
||||||
|
{darkMode && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: 1,
|
||||||
|
background: 'linear-gradient(90deg, transparent, rgba(0,212,255,0.5) 50%, transparent)',
|
||||||
|
borderRadius: '18px 18px 0 0',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 42, height: 42, borderRadius: 12, flexShrink: 0,
|
||||||
|
backgroundColor: `${catColor}${darkMode ? '20' : '14'}`,
|
||||||
|
border: `1.5px solid ${catColor}${darkMode ? '50' : '30'}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: darkMode ? `0 0 12px ${catColor}30` : 'none',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 14, height: 14, borderRadius: '50%', backgroundColor: catColor,
|
||||||
|
boxShadow: darkMode ? `0 0 8px ${catColor}` : 'none',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 12, color: subtitleColor, marginBottom: 3, letterSpacing: '-0.01em' }}>
|
||||||
|
{typeLabel[node.type] || node.type}
|
||||||
|
</p>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 700, color: titleColor, letterSpacing: '-0.03em', lineHeight: 1.2, margin: 0 }}>
|
||||||
|
{node.label}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
width: 28, height: 28, borderRadius: 8,
|
||||||
|
backgroundColor: closeBtnBg, border: 'none', cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
color: closeBtnColor, transition: 'all 0.15s ease',
|
||||||
|
}}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ padding: '14px 18px', flex: 1 }}>
|
||||||
|
{mat ? (
|
||||||
|
<div>
|
||||||
|
{/* 技术规格 */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<p style={{ fontSize: 11, fontWeight: 600, color: sectionLabel, marginBottom: 8, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||||
|
技术规格
|
||||||
|
</p>
|
||||||
|
<div style={{ backgroundColor: specBg, borderRadius: 12, padding: '12px 14px', border: `1px solid ${specBorder}` }}>
|
||||||
|
<p style={{ fontSize: 13, color: specText, lineHeight: 1.8, letterSpacing: '-0.01em', margin: 0 }}>
|
||||||
|
{mat.spec}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 字段列表 */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Row label="料号" darkMode={darkMode}>
|
||||||
|
<span style={{ fontFamily: 'SF Mono,Menlo,Monaco,monospace', fontSize: 12, color: darkMode ? '#6aaccc' : undefined }}>
|
||||||
|
{mat.id}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="数量" darkMode={darkMode}>
|
||||||
|
<strong style={{ fontWeight: 700, fontSize: 15, color: darkMode ? '#e8f4ff' : '#1c1c1e' }}>{mat.qty}</strong>
|
||||||
|
<span style={{ fontSize: 12, color: subtitleColor, marginLeft: 3 }}>件</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="物料分类" darkMode={darkMode}>
|
||||||
|
<span style={{
|
||||||
|
color: catColor, fontWeight: 600,
|
||||||
|
textShadow: darkMode ? `0 0 8px ${catColor}60` : 'none',
|
||||||
|
}}>{mat.category}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="前缀类型" darkMode={darkMode}>
|
||||||
|
<span style={{ color: darkMode ? '#a8d8ff' : '#1c1c1e' }}>
|
||||||
|
{prefixLabels[mat.prefix] || mat.prefix}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
{/* 账龄行 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '10px 0',
|
||||||
|
borderBottom: `1px solid ${darkMode ? 'rgba(0,212,255,0.08)' : '#f2f2f7'}`,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 13, color: darkMode ? '#4a7fa8' : '#8e8e93' }}>账龄</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 13, fontWeight: 600, padding: '4px 12px', borderRadius: 20,
|
||||||
|
backgroundColor: `${agColor}${darkMode ? '20' : '12'}`,
|
||||||
|
color: agColor,
|
||||||
|
border: `1px solid ${agColor}${darkMode ? '50' : '30'}`,
|
||||||
|
boxShadow: darkMode ? `0 0 8px ${agColor}30` : 'none',
|
||||||
|
}}>{mat.aging} 天</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 关联项目卡片 */}
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: projBg, borderRadius: 12,
|
||||||
|
padding: '12px 14px', border: `1px solid ${projBorder}`,
|
||||||
|
boxShadow: darkMode ? '0 0 16px rgba(167,139,250,0.08)' : 'none',
|
||||||
|
}}>
|
||||||
|
<p style={{ fontSize: 12, color: sectionLabel, marginBottom: 5 }}>关联项目</p>
|
||||||
|
<p style={{ fontSize: 16, fontWeight: 700, color: projTitle, letterSpacing: '-0.03em', margin: 0,
|
||||||
|
textShadow: darkMode ? `0 0 12px ${projTitle}50` : 'none',
|
||||||
|
}}>
|
||||||
|
{projectMap[mat.projectId]?.name || mat.projectId}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: projDesc, marginTop: 4 }}>
|
||||||
|
{projectMap[mat.projectId]?.desc || ''}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 11, fontFamily: 'SF Mono,Menlo,Monaco,monospace', color: projId, marginTop: 6 }}>
|
||||||
|
{mat.projectId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{/* 分组节点:物料数量 */}
|
||||||
|
{node.count !== undefined && (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: groupBg, borderRadius: 12,
|
||||||
|
padding: '18px', border: `1px solid ${specBorder}`,
|
||||||
|
marginBottom: 14, textAlign: 'center',
|
||||||
|
boxShadow: darkMode ? `0 0 20px ${catColor}15` : 'none',
|
||||||
|
}}>
|
||||||
|
<p style={{ fontSize: 13, color: subtitleColor, marginBottom: 6 }}>关联物料数</p>
|
||||||
|
<p style={{
|
||||||
|
fontSize: 40, fontWeight: 800, color: catColor,
|
||||||
|
letterSpacing: '-0.05em', lineHeight: 1, margin: 0,
|
||||||
|
textShadow: darkMode ? `0 0 20px ${catColor}70` : 'none',
|
||||||
|
}}>
|
||||||
|
{node.count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.type === 'category' && (
|
||||||
|
<p style={{ fontSize: 14, color: darkMode ? '#7aadcc' : '#6d6d72', lineHeight: 1.7, letterSpacing: '-0.01em' }}>
|
||||||
|
该分类下包含所有归属于{' '}
|
||||||
|
<strong style={{ color: catColor, textShadow: darkMode ? `0 0 8px ${catColor}60` : 'none' }}>
|
||||||
|
{node.label}
|
||||||
|
</strong>{' '}
|
||||||
|
的采购物料,点击物料节点查看详细规格。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.type === 'aging' && node.agingRange && (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: groupBg, borderRadius: 12,
|
||||||
|
padding: '14px', border: `1px solid ${specBorder}`,
|
||||||
|
}}>
|
||||||
|
<p style={{ fontSize: 13, color: subtitleColor, marginBottom: 8 }}>账龄区间</p>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 14, fontWeight: 600, padding: '5px 14px', borderRadius: 20,
|
||||||
|
backgroundColor: `${agColors[node.agingRange] || '#94a3b8'}${darkMode ? '20' : '12'}`,
|
||||||
|
color: agColors[node.agingRange] || '#94a3b8',
|
||||||
|
border: `1px solid ${agColors[node.agingRange] || '#94a3b8'}${darkMode ? '50' : '30'}`,
|
||||||
|
boxShadow: darkMode ? `0 0 8px ${agColors[node.agingRange] || '#94a3b8'}30` : 'none',
|
||||||
|
}}>
|
||||||
|
{node.agingRange} 天
|
||||||
|
</span>
|
||||||
|
<p style={{ fontSize: 13, color: darkMode ? '#7aadcc' : '#6d6d72', marginTop: 12, lineHeight: 1.7 }}>
|
||||||
|
该账龄段物料需关注资金占用,建议优先推进采购收货。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { rawMaterials } from '../data/materials';
|
||||||
|
|
||||||
|
const lightCategoryColors: Record<string, string> = {
|
||||||
|
'温控系统': '#0A7AFF',
|
||||||
|
'气路系统': '#7B6CF7',
|
||||||
|
'阀件系统': '#F07830',
|
||||||
|
'结构件': '#28A866',
|
||||||
|
};
|
||||||
|
const lightAgingColors: Record<string, string> = {
|
||||||
|
'60-90': '#28A866',
|
||||||
|
'90-120': '#E08C00',
|
||||||
|
'120-150':'#D94040',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatsOverviewProps {
|
||||||
|
filterCategory: string | null;
|
||||||
|
filterAging: string | null;
|
||||||
|
onFilterCategory: (c: string | null) => void;
|
||||||
|
onFilterAging: (a: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsOverview({ filterCategory, filterAging, onFilterCategory, onFilterAging }: StatsOverviewProps) {
|
||||||
|
const materials = rawMaterials;
|
||||||
|
const agingStats = [
|
||||||
|
{ range: '60-90', label: '60~90 天', count: materials.filter(m => m.aging==='60-90').length },
|
||||||
|
{ range: '90-120', label: '90~120 天', count: materials.filter(m => m.aging==='90-120').length },
|
||||||
|
{ range: '120-150',label: '120~150 天', count: materials.filter(m => m.aging==='120-150').length },
|
||||||
|
];
|
||||||
|
const categoryStats = [
|
||||||
|
{ name: '温控系统', count: materials.filter(m => m.category==='温控系统').length },
|
||||||
|
{ name: '气路系统', count: materials.filter(m => m.category==='气路系统').length },
|
||||||
|
{ name: '阀件系统', count: materials.filter(m => m.category==='阀件系统').length },
|
||||||
|
{ name: '结构件', count: materials.filter(m => m.category==='结构件').length },
|
||||||
|
];
|
||||||
|
const totalQty = materials.reduce((s, m) => s + m.qty, 0);
|
||||||
|
const projectCount = new Set(materials.map(m => m.projectId)).size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:20 }}>
|
||||||
|
{/* Metric cards */}
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:8 }}>
|
||||||
|
{[
|
||||||
|
{ label:'物料种类', value: materials.length, unit:'种' },
|
||||||
|
{ label:'关联项目', value: projectCount, unit:'个' },
|
||||||
|
{ label:'物料总量', value: totalQty, unit:'件' },
|
||||||
|
].map(stat => (
|
||||||
|
<div key={stat.label} style={{
|
||||||
|
backgroundColor:'#ffffff', border:'1px solid #e8e8ed',
|
||||||
|
borderRadius:14, padding:'14px 10px', textAlign:'center',
|
||||||
|
boxShadow:'0 1px 5px rgba(0,0,0,0.06)',
|
||||||
|
}}>
|
||||||
|
<p style={{ fontSize:26, fontWeight:700, letterSpacing:'-0.04em', color:'#007AFF', lineHeight:1 }}>{stat.value}</p>
|
||||||
|
<p style={{ fontSize:11, color:'#aeaeb2', marginTop:4, letterSpacing:'-0.01em' }}>{stat.unit}</p>
|
||||||
|
<p style={{ fontSize:12, color:'#6d6d72', marginTop:2, letterSpacing:'-0.01em' }}>{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aging */}
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize:13, fontWeight:600, color:'#48484a', marginBottom:10, letterSpacing:'-0.01em' }}>账龄分布</p>
|
||||||
|
<div style={{ display:'flex', flexDirection:'column', gap:8 }}>
|
||||||
|
{agingStats.map(({ range, label, count }) => {
|
||||||
|
const color = lightAgingColors[range];
|
||||||
|
const pct = Math.round((count / materials.length) * 100);
|
||||||
|
const active = filterAging === range;
|
||||||
|
return (
|
||||||
|
<button key={range} onClick={() => onFilterAging(active ? null : range)}
|
||||||
|
style={{
|
||||||
|
padding:'12px 14px', borderRadius:12,
|
||||||
|
border: active ? `1.5px solid ${color}60` : '1px solid #e8e8ed',
|
||||||
|
backgroundColor: active ? `${color}10` : '#ffffff',
|
||||||
|
textAlign:'left', cursor:'pointer',
|
||||||
|
boxShadow: active ? `0 2px 10px ${color}22` : '0 1px 3px rgba(0,0,0,0.04)',
|
||||||
|
transition:'all 0.18s ease', outline:'none',
|
||||||
|
}}>
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:9 }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||||
|
<div style={{ width:8, height:8, borderRadius:'50%', backgroundColor:color, flexShrink:0 }} />
|
||||||
|
<span style={{ fontSize:14, fontWeight:500, color: active ? color : '#1c1c1e', letterSpacing:'-0.01em' }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize:13, color:'#8e8e93', fontWeight:500 }}>{count} 种</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height:5, borderRadius:5, backgroundColor:'#e8e8ed', overflow:'hidden' }}>
|
||||||
|
<div style={{ height:'100%', width:`${pct}%`, backgroundColor:color, borderRadius:5, transition:'width 0.4s ease', opacity:0.85 }} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize:13, fontWeight:600, color:'#48484a', marginBottom:10, letterSpacing:'-0.01em' }}>物料分类</p>
|
||||||
|
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8 }}>
|
||||||
|
{categoryStats.map(({ name, count }) => {
|
||||||
|
const color = lightCategoryColors[name] || '#94a3b8';
|
||||||
|
const active = filterCategory === name;
|
||||||
|
return (
|
||||||
|
<button key={name} onClick={() => onFilterCategory(active ? null : name)}
|
||||||
|
style={{
|
||||||
|
padding:'12px 14px', borderRadius:12,
|
||||||
|
border: active ? `1.5px solid ${color}50` : '1px solid #e8e8ed',
|
||||||
|
backgroundColor: active ? `${color}0e` : '#ffffff',
|
||||||
|
textAlign:'left', cursor:'pointer',
|
||||||
|
boxShadow: active ? `0 2px 10px ${color}20` : '0 1px 3px rgba(0,0,0,0.04)',
|
||||||
|
transition:'all 0.18s ease', outline:'none',
|
||||||
|
}}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:7, marginBottom:8 }}>
|
||||||
|
<div style={{ width:8, height:8, borderRadius:'50%', backgroundColor:color, flexShrink:0 }} />
|
||||||
|
<span style={{ fontSize:13, fontWeight:500, color:'#1c1c1e', letterSpacing:'-0.01em', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{name}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize:28, fontWeight:700, color: active ? color : '#1c1c1e', letterSpacing:'-0.05em', lineHeight:1, margin:0 }}>{count}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: number;
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_CMDS = ['账龄分析', '物料关系', '采购建议', '风险预警', '项目汇总'];
|
||||||
|
|
||||||
|
const AI_REPLIES: Record<string, string> = {
|
||||||
|
'账龄分析': '📊 账龄分析完成:\n• 120-150天(高风险):6种物料,涉及阀件系统4种、气路系统2种\n• 90-120天(中风险):8种物料,建议优先跟进JO/JY系列\n• 60-90天(低风险):6种物料,状态正常\n\n建议立即对JO-7834(气动蝶阀)和JY-2201(阀门定位器)采取行动。',
|
||||||
|
'物料关系': '🕸 物料关系洞察:\n• 温控系统:5种物料,分布在PRJ-002、PRJ-008、PRJ-014等6个项目\n• 气路系统:6种物料,与PRJ-017高度关联(3种物料共享)\n• 阀件系统:5种物料,账龄风险最高,集中在PRJ-011\n• 结构件:4种物料,分布均匀,账龄健康',
|
||||||
|
'采购建议': '⚡ 采购优化建议:\n1. 紧急处理:气动蝶阀、阀门定位器需立即启动紧急采购流程\n2. 批量合并:PRJ-015和PRJ-017中有3种相同物料,建议合并采购节省15%成本\n3. 供应商整合:温控系统4种物料可考虑统一供应商',
|
||||||
|
'风险预警': '⚠ 当前风险汇总:\n🔴 高风险(2项):JO-7834 气动蝶阀(147天)、JY-2201 阀门定位器(135天)\n🟡 中风险(4项):压力变送器(122天)、气缸(128天)、电动执行机构(132天)、过滤调压阀(140天)\n\n建议24小时内完成高风险物料的采购决策。',
|
||||||
|
'项目汇总': '📋 项目采购汇总:\n• PRJ-017:关联5种物料,采购进度60%\n• PRJ-015:新增结构件采购需求待启动\n• PRJ-011:阀件系统账龄风险较高\n• PRJ-002:温度传感器已完成入库\n\n整体采购完成率:72%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeStr = () => {
|
||||||
|
const now = new Date();
|
||||||
|
return `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BottomChatBarProps {
|
||||||
|
activeAgentName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BottomChatBar({ activeAgentName }: BottomChatBarProps) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([{
|
||||||
|
id: 0, role: 'ai',
|
||||||
|
content: '您好!我是AI采购助手,已连接采购域知识图谱。您可以询问物料账龄状态、采购建议或项目进展。',
|
||||||
|
time: getTimeStr(),
|
||||||
|
}]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}, [messages, isTyping]);
|
||||||
|
|
||||||
|
const sendMessage = (text: string) => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
setMessages(prev => [...prev, { id: Date.now(), role: 'user', content: text, time: getTimeStr() }]);
|
||||||
|
setInput('');
|
||||||
|
setIsTyping(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const matched = Object.keys(AI_REPLIES).find(k => text.includes(k));
|
||||||
|
const reply = matched ? AI_REPLIES[matched]
|
||||||
|
: activeAgentName
|
||||||
|
? `正在调用【${activeAgentName}】分析:"${text}"\n\n已检索到相关物料20种,涉及项目14个。建议从账龄风险最高的阀件系统开始着手处理。`
|
||||||
|
: `收到:"${text}"\n\n正在从知识图谱中检索相关数据,发现潜在关联节点8个。`;
|
||||||
|
setMessages(prev => [...prev, { id: Date.now() + 1, role: 'ai', content: reply, time: getTimeStr() }]);
|
||||||
|
setIsTyping(false);
|
||||||
|
}, 900 + Math.random() * 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatHeight = expanded ? 180 : 90;
|
||||||
|
// 弧形区域高度提高,让弧线更优美
|
||||||
|
const ARC_H = 64;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// 外层容器:左右留白,不遮盖两侧工作区
|
||||||
|
<div
|
||||||
|
className="space-animate-up"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '0 252px',
|
||||||
|
paddingBottom: 0,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible', // 允许弧顶超出
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 内层面板 */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
SVG 弧形顶边:viewBox 高度加大,控制点更低(y 更负),弧顶更高
|
||||||
|
填充背景 + 描边同一路径,100% 重合
|
||||||
|
*/}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 1000 120"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 0, left: 0,
|
||||||
|
width: '100%', height: ARC_H,
|
||||||
|
pointerEvents: 'none', zIndex: 1,
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'visible',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
更优美的弧线:
|
||||||
|
两端从底部(y=120)出发,控制点 y=-50(深入负值),形成高耸优雅的拱形
|
||||||
|
控制点靠近两端(x=150/850),让弧线中部平缓、两端陡升
|
||||||
|
*/}
|
||||||
|
<path
|
||||||
|
d="M 0,120 C 150,-50 850,-50 1000,120 L 1000,120 L 0,120 Z"
|
||||||
|
fill="rgba(0,20,55,0.86)"
|
||||||
|
/>
|
||||||
|
{/* 同路径描边 */}
|
||||||
|
<path
|
||||||
|
d="M 0,120 C 150,-50 850,-50 1000,120"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(0,212,255,0.55)"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
/>
|
||||||
|
{/* 左右端发光点(弧线端点 y=120) */}
|
||||||
|
<circle cx="0" cy="120" r="5" fill="rgba(0,212,255,0.85)" />
|
||||||
|
<circle cx="1000" cy="120" r="5" fill="rgba(0,212,255,0.85)" />
|
||||||
|
{/* 弧顶发光点(控制点 y=-50,实际顶点约在 y=2 附近) */}
|
||||||
|
<circle cx="500" cy="2" r="3.5" fill="rgba(0,212,255,0.55)" />
|
||||||
|
{/* 1/4 & 3/4 辅助光点(弧线 t=0.25/0.75 处大约 y=35) */}
|
||||||
|
<circle cx="200" cy="38" r="2" fill="rgba(0,212,255,0.35)" />
|
||||||
|
<circle cx="800" cy="38" r="2" fill="rgba(0,212,255,0.35)" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 弧形下方的实体背景(紧接弧形底边,无缝衔接) */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: ARC_H - 1, // 上移 1px 消除 SVG 与 div 之间的像素缝隙
|
||||||
|
left: 0, right: 0, bottom: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
background: 'linear-gradient(to bottom, rgba(0,20,55,0.86), rgba(0,10,30,0.94))',
|
||||||
|
backdropFilter: 'blur(16px)',
|
||||||
|
WebkitBackdropFilter: 'blur(16px)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 内容层 */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 2, paddingTop: ARC_H }}>
|
||||||
|
|
||||||
|
{/* 消息区 */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="space-scroll"
|
||||||
|
style={{
|
||||||
|
height: chatHeight,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '8px 28px 4px',
|
||||||
|
transition: 'height 0.25s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} style={{ display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start', marginBottom: 7 }}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<span style={{
|
||||||
|
width: 24, height: 24, borderRadius: '50%',
|
||||||
|
background: 'rgba(0,212,255,0.15)', border: '1px solid rgba(0,212,255,0.3)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 10, color: '#00d4ff', flexShrink: 0, marginRight: 8, marginTop: 2,
|
||||||
|
}}>AI</span>
|
||||||
|
)}
|
||||||
|
<div style={{ maxWidth: '72%' }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: msg.role === 'user' ? '12px 12px 2px 12px' : '12px 12px 12px 2px',
|
||||||
|
background: msg.role === 'user' ? 'rgba(0,122,255,0.22)' : 'rgba(0,20,60,0.55)',
|
||||||
|
border: msg.role === 'user' ? '1px solid rgba(0,122,255,0.3)' : '1px solid rgba(0,212,255,0.12)',
|
||||||
|
color: msg.role === 'user' ? '#c8e8ff' : '#a8d8ff',
|
||||||
|
fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-line',
|
||||||
|
}}>{msg.content}</div>
|
||||||
|
<div style={{ color: '#2a5070', fontSize: 10, marginTop: 2, textAlign: msg.role === 'user' ? 'right' : 'left' }}>{msg.time}</div>
|
||||||
|
</div>
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<span style={{
|
||||||
|
width: 24, height: 24, borderRadius: '50%',
|
||||||
|
background: 'rgba(0,122,255,0.2)', border: '1px solid rgba(0,122,255,0.35)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 10, color: '#5aabff', flexShrink: 0, marginLeft: 8, marginTop: 2,
|
||||||
|
}}>我</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isTyping && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 7 }}>
|
||||||
|
<span style={{ width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,212,255,0.15)', border: '1px solid rgba(0,212,255,0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: '#00d4ff', flexShrink: 0 }}>AI</span>
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '12px 12px 12px 2px', background: 'rgba(0,20,60,0.55)', border: '1px solid rgba(0,212,255,0.12)', display: 'flex', gap: 5, alignItems: 'center' }}>
|
||||||
|
{[0,1,2].map(i => (
|
||||||
|
<span key={i} style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#00d4ff', animation: `space-pulse 1s ${i*0.2}s ease-in-out infinite` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<div style={{ padding: '4px 28px 16px' }}>
|
||||||
|
{/* 快捷指令 */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||||
|
{QUICK_CMDS.map(cmd => (
|
||||||
|
<button key={cmd} onClick={() => sendMessage(cmd)} style={{
|
||||||
|
padding: '4px 12px', borderRadius: 20,
|
||||||
|
border: '1px solid rgba(0,212,255,0.2)',
|
||||||
|
background: 'rgba(0,212,255,0.06)',
|
||||||
|
color: '#6aaccc', fontSize: 12, cursor: 'pointer', transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background='rgba(0,212,255,0.15)'; e.currentTarget.style.color='#00d4ff'; e.currentTarget.style.borderColor='rgba(0,212,255,0.4)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background='rgba(0,212,255,0.06)'; e.currentTarget.style.color='#6aaccc'; e.currentTarget.style.borderColor='rgba(0,212,255,0.2)'; }}
|
||||||
|
>{cmd}</button>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setExpanded(v => !v)} style={{
|
||||||
|
marginLeft: 'auto', padding: '4px 10px', borderRadius: 8,
|
||||||
|
border: '1px solid rgba(255,255,255,0.06)', background: 'transparent',
|
||||||
|
color: '#2a5070', fontSize: 11, cursor: 'pointer',
|
||||||
|
}}>{expanded ? '收起 ↓' : '展开 ↑'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入框行 */}
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
{activeAgentName && (
|
||||||
|
<span style={{ padding: '5px 10px', borderRadius: 8, background: 'rgba(0,212,255,0.1)', border: '1px solid rgba(0,212,255,0.25)', color: '#00d4ff', fontSize: 12, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
@{activeAgentName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(input); } }}
|
||||||
|
placeholder={activeAgentName ? `向【${activeAgentName}】提问...` : '向AI采购助手提问,如:哪些物料存在账龄风险?'}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '10px 18px', borderRadius: 24,
|
||||||
|
background: 'rgba(0,30,70,0.6)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.2)',
|
||||||
|
color: '#c8e4ff', fontSize: 14, outline: 'none',
|
||||||
|
transition: 'border-color 0.15s ease',
|
||||||
|
}}
|
||||||
|
onFocus={e => (e.target.style.borderColor = 'rgba(0,212,255,0.5)')}
|
||||||
|
onBlur={e => (e.target.style.borderColor = 'rgba(0,212,255,0.2)')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => sendMessage(input)}
|
||||||
|
disabled={!input.trim() || isTyping}
|
||||||
|
style={{
|
||||||
|
width: 42, height: 42, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: input.trim() && !isTyping ? 'linear-gradient(135deg, #0066cc, #00aaff)' : 'rgba(0,50,100,0.3)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.3)',
|
||||||
|
color: input.trim() && !isTyping ? '#ffffff' : '#2a5070',
|
||||||
|
fontSize: 18, cursor: input.trim() && !isTyping ? 'pointer' : 'not-allowed',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
boxShadow: input.trim() && !isTyping ? '0 0 14px rgba(0,170,255,0.4)' : 'none',
|
||||||
|
}}
|
||||||
|
>↑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import KnowledgeGraph from '../KnowledgeGraph';
|
||||||
|
import type { Material } from '../../data/materials';
|
||||||
|
|
||||||
|
type ViewMode = 'category' | 'project' | 'aging';
|
||||||
|
|
||||||
|
interface SelectedNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
color: string;
|
||||||
|
material?: Material;
|
||||||
|
count?: number;
|
||||||
|
agingRange?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CenterGraphAreaProps {
|
||||||
|
onNodeSelect: (node: SelectedNode | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewLabels: Record<ViewMode, string> = {
|
||||||
|
category: '分类视图',
|
||||||
|
project: '项目视图',
|
||||||
|
aging: '账龄视图',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CenterGraphArea({ onNodeSelect }: CenterGraphAreaProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('category');
|
||||||
|
const [filterCategory, setFilterCategory] = useState<string | null>(null);
|
||||||
|
const [filterAging, setFilterAging] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: '10px',
|
||||||
|
gap: 8,
|
||||||
|
}}>
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{/* 视图切换 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 3,
|
||||||
|
gap: 2,
|
||||||
|
background: 'rgba(0,20,60,0.6)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.2)',
|
||||||
|
}}>
|
||||||
|
{(Object.keys(viewLabels) as ViewMode[]).map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => { setViewMode(mode); onNodeSelect(null); }}
|
||||||
|
style={{
|
||||||
|
padding: '5px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
background: viewMode === mode ? 'rgba(0,212,255,0.2)' : 'transparent',
|
||||||
|
color: viewMode === mode ? '#00d4ff' : '#4a7fa8',
|
||||||
|
boxShadow: viewMode === mode ? '0 0 10px rgba(0,212,255,0.2)' : 'none',
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{viewLabels[mode]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 激活的筛选标签 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1 }}>
|
||||||
|
{filterCategory && (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '3px 10px', borderRadius: 16,
|
||||||
|
fontSize: 11, fontWeight: 500,
|
||||||
|
background: 'rgba(0,212,255,0.12)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.3)',
|
||||||
|
color: '#00d4ff',
|
||||||
|
}}>
|
||||||
|
{filterCategory}
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterCategory(null)}
|
||||||
|
style={{
|
||||||
|
width: 14, height: 14, borderRadius: '50%',
|
||||||
|
background: 'rgba(0,212,255,0.2)',
|
||||||
|
border: 'none', cursor: 'pointer', color: '#00d4ff',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10,
|
||||||
|
}}
|
||||||
|
>×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{filterAging && (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '3px 10px', borderRadius: 16,
|
||||||
|
fontSize: 11, fontWeight: 500,
|
||||||
|
background: 'rgba(255,179,64,0.12)',
|
||||||
|
border: '1px solid rgba(255,179,64,0.3)',
|
||||||
|
color: '#ffb340',
|
||||||
|
}}>
|
||||||
|
账龄 {filterAging} 天
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterAging(null)}
|
||||||
|
style={{
|
||||||
|
width: 14, height: 14, borderRadius: '50%',
|
||||||
|
background: 'rgba(255,179,64,0.2)',
|
||||||
|
border: 'none', cursor: 'pointer', color: '#ffb340',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10,
|
||||||
|
}}
|
||||||
|
>×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{ color: '#2a5070', fontSize: 11, whiteSpace: 'nowrap' }}>
|
||||||
|
点击节点 · 滚轮缩放
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图谱容器——全息屏幕效果 */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(0,212,255,0.35)',
|
||||||
|
boxShadow:
|
||||||
|
'0 0 30px rgba(0,212,255,0.12), ' +
|
||||||
|
'0 0 60px rgba(0,100,200,0.08), ' +
|
||||||
|
'inset 0 0 30px rgba(0,30,80,0.2)',
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
{/* 四角装饰 */}
|
||||||
|
{['top-left', 'top-right', 'bottom-left', 'bottom-right'].map(pos => {
|
||||||
|
const isTop = pos.includes('top');
|
||||||
|
const isLeft = pos.includes('left');
|
||||||
|
return (
|
||||||
|
<div key={pos} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: isTop ? 8 : undefined,
|
||||||
|
bottom: !isTop ? 8 : undefined,
|
||||||
|
left: isLeft ? 8 : undefined,
|
||||||
|
right: !isLeft ? 8 : undefined,
|
||||||
|
width: 16, height: 16,
|
||||||
|
zIndex: 5, pointerEvents: 'none',
|
||||||
|
borderTop: isTop ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
borderBottom: !isTop ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
borderLeft: isLeft ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
borderRight: !isLeft ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<KnowledgeGraph
|
||||||
|
onNodeSelect={onNodeSelect}
|
||||||
|
filterCategory={filterCategory}
|
||||||
|
filterAging={filterAging}
|
||||||
|
viewMode={viewMode}
|
||||||
|
darkMode={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SelectedNode };
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Notice {
|
||||||
|
id: number;
|
||||||
|
level: 'danger' | 'warning' | 'info' | 'success';
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
time: string;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTICES: Notice[] = [
|
||||||
|
{ id: 1, level: 'danger', title: '账龄超期预警', desc: '气动蝶阀(JO-7834)账龄已达147天,超出阈值,需立即处理', time: '2分钟前', tag: '阀件系统' },
|
||||||
|
{ id: 2, level: 'danger', title: '账龄超期预警', desc: '阀门定位器(JY-2201)账龄135天,建议尽快核实采购状态', time: '8分钟前', tag: '阀件系统' },
|
||||||
|
{ id: 3, level: 'warning', title: '采购待确认', desc: 'PRJ-017项目中3种气路系统物料尚未完成采购确认', time: '15分钟前', tag: '气路系统' },
|
||||||
|
{ id: 4, level: 'warning', title: '账龄接近阈值', desc: '压力变送器(Q1-5582)账龄122天,接近120天预警线', time: '32分钟前', tag: '温控系统' },
|
||||||
|
{ id: 5, level: 'info', title: '新增采购需求', desc: 'PRJ-015项目新增结构件采购需求,请采购团队跟进', time: '1小时前', tag: '结构件' },
|
||||||
|
{ id: 6, level: 'info', title: '知识图谱更新', desc: '物料关系图谱新增PRJ-019项目节点,共关联5种物料', time: '2小时前', tag: '系统' },
|
||||||
|
{ id: 7, level: 'success', title: '采购完成', desc: '温度传感器(WK-1023) PRJ-002项目采购已完成入库', time: '3小时前', tag: '温控系统' },
|
||||||
|
{ id: 8, level: 'success', title: '采购完成', desc: '流量计(QS-3301) PRJ-008项目已验收完毕,账龄归零', time: '昨天', tag: '气路系统' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const levelConfig = {
|
||||||
|
danger: { color: '#ff4d6a', bg: 'rgba(255,77,106,0.08)', border: 'rgba(255,77,106,0.28)', icon: '⚠', badge: '风险' },
|
||||||
|
warning: { color: '#ffb340', bg: 'rgba(255,179,64,0.08)', border: 'rgba(255,179,64,0.28)', icon: '●', badge: '预警' },
|
||||||
|
info: { color: '#00d4ff', bg: 'rgba(0,212,255,0.06)', border: 'rgba(0,212,255,0.22)', icon: 'ℹ', badge: '通知' },
|
||||||
|
success: { color: '#00e599', bg: 'rgba(0,229,153,0.06)', border: 'rgba(0,229,153,0.22)', icon: '✓', badge: '完成' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
左侧面板轮廓(参考红笔迹):
|
||||||
|
SVG 坐标系 220×1000(宽×高,高度用大数覆盖全高)
|
||||||
|
|
||||||
|
轮廓路径(顺时针):
|
||||||
|
- 左上角 (0,0) → 顶边横线到弧起点 (150,0)
|
||||||
|
- 右上大弧:贝塞尔从 (150,0) 经控制点 (220,0)(220,80) 到 (220,80)
|
||||||
|
- 右侧边缘向外凸出弧(船身感):
|
||||||
|
从 (220,80) 经控制点 (240, 50%) 到 (220, 底部-80)
|
||||||
|
即用贝塞尔 C 向右凸出约 24px
|
||||||
|
- 右下弧回:从右侧凸出底部收回到 (150, 底部)
|
||||||
|
- 底边横线回 (0, 底部)
|
||||||
|
- 左侧直线回 (0,0)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function LeftNoticePanel() {
|
||||||
|
const [filter, setFilter] = useState<Notice['level'] | 'all'>('all');
|
||||||
|
const filtered = filter === 'all' ? NOTICES : NOTICES.filter(n => n.level === filter);
|
||||||
|
const dangerCount = NOTICES.filter(n => n.level === 'danger').length;
|
||||||
|
const warningCount = NOTICES.filter(n => n.level === 'warning').length;
|
||||||
|
|
||||||
|
// SVG 坐标宽度固定 220,高度用 10000 覆盖任意高度
|
||||||
|
// 右侧凸出弧:中部向右凸出 28px
|
||||||
|
const W = 220;
|
||||||
|
const BULGE = 28; // 右侧凸出量
|
||||||
|
|
||||||
|
// 完整轮廓路径:
|
||||||
|
// 顶部:0,0 → 150,0(横线)→ 贝塞尔弧到 220,80(右上角)
|
||||||
|
// 右侧:220,80 → 贝塞尔向右凸出 → 220,9920(接近底部)
|
||||||
|
// 底部:直线回 0,10000
|
||||||
|
// 左侧:直线回 0,0
|
||||||
|
const outlinePath = `
|
||||||
|
M 0,0
|
||||||
|
L 150,0
|
||||||
|
Q ${W},0 ${W},80
|
||||||
|
C ${W + BULGE},2000 ${W + BULGE},8000 ${W},9920
|
||||||
|
L 0,10000
|
||||||
|
Z
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 仅描边路径(不含底边和左边,只画可见轮廓线)
|
||||||
|
const strokePath = `
|
||||||
|
M 0,0.5
|
||||||
|
L 150,0.5
|
||||||
|
Q ${W - 0.5},0.5 ${W - 0.5},80
|
||||||
|
C ${W - 0.5 + BULGE},2000 ${W - 0.5 + BULGE},8000 ${W - 0.5},9920
|
||||||
|
`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-animate-left"
|
||||||
|
style={{
|
||||||
|
width: W + BULGE, // 容器宽度包含凸出部分
|
||||||
|
height: '100%',
|
||||||
|
flexShrink: 0,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible', // 允许右侧凸出弧溢出
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* SVG:背景填充 + 轮廓描边,完全同路径 */}
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 0, left: 0,
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
pointerEvents: 'none', zIndex: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
}}
|
||||||
|
viewBox={`0 0 ${W + BULGE} 10000`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="leftPanelBg2" x1="1" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(4,28,75,0.85)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(2,14,48,0.75)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{/* 背景填充 */}
|
||||||
|
<path d={outlinePath} fill="url(#leftPanelBg2)" />
|
||||||
|
{/* 轮廓描边(仅顶部+右侧可见部分) */}
|
||||||
|
<path
|
||||||
|
d={strokePath}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(0,212,255,0.45)"
|
||||||
|
strokeWidth="1"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{/* 左侧竖线(轻淡) */}
|
||||||
|
<line x1="0.5" y1="0" x2="0.5" y2="10000"
|
||||||
|
stroke="rgba(0,212,255,0.15)" strokeWidth="1"
|
||||||
|
vectorEffect="non-scaling-stroke" />
|
||||||
|
{/* 顶部弧起点发光 */}
|
||||||
|
<circle cx="150" cy="1" r="3" fill="rgba(0,212,255,0.9)"
|
||||||
|
vectorEffect="non-scaling-stroke" />
|
||||||
|
{/* 右上弧落点发光 */}
|
||||||
|
<circle cx={W} cy="80" r="2.5" fill="rgba(0,212,255,0.6)"
|
||||||
|
vectorEffect="non-scaling-stroke" />
|
||||||
|
{/* 右侧凸出最宽处发光(中部) */}
|
||||||
|
<circle cx={W + BULGE} cy="5000" r="2" fill="rgba(0,212,255,0.25)"
|
||||||
|
vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 内容层——宽度限制在 W 内,凸出部分是纯装饰 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1,
|
||||||
|
width: W,
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
}}>
|
||||||
|
{/* 标题区 */}
|
||||||
|
<div style={{ padding: '14px 12px 10px 14px', paddingRight: 14, flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<span style={{ width: 3, height: 14, borderRadius: 2, background: 'linear-gradient(to bottom,#00d4ff,#0066aa)', display: 'inline-block' }} />
|
||||||
|
<span style={{ color: '#a8d8ff', fontSize: 13, fontWeight: 600, letterSpacing: '0.04em' }}>采购通知</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{dangerCount > 0 && (
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 8, background: 'rgba(255,77,106,0.2)', color: '#ff4d6a', fontSize: 10, fontWeight: 700 }}>
|
||||||
|
{dangerCount}险
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{warningCount > 0 && (
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 8, background: 'rgba(255,179,64,0.15)', color: '#ffb340', fontSize: 10, fontWeight: 700 }}>
|
||||||
|
{warningCount}警
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 10 }}>
|
||||||
|
{(['all', 'danger', 'warning', 'info', 'success'] as const).map(lvl => {
|
||||||
|
const isActive = filter === lvl;
|
||||||
|
const cfg = lvl !== 'all' ? levelConfig[lvl] : null;
|
||||||
|
return (
|
||||||
|
<button key={lvl} onClick={() => setFilter(lvl)} style={{
|
||||||
|
flex: 1, padding: '3px 0', borderRadius: 5,
|
||||||
|
fontSize: 10, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1px solid ${isActive ? (cfg?.border || 'rgba(0,212,255,0.4)') : 'rgba(255,255,255,0.07)'}`,
|
||||||
|
background: isActive ? (cfg?.bg || 'rgba(0,212,255,0.12)') : 'transparent',
|
||||||
|
color: isActive ? (cfg?.color || '#00d4ff') : '#4a7fa8',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}>
|
||||||
|
{lvl === 'all' ? '全' : cfg!.badge}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'rgba(0,212,255,0.1)', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px 8px 12px' }}>
|
||||||
|
{filtered.map((notice, idx) => {
|
||||||
|
const cfg = levelConfig[notice.level];
|
||||||
|
return (
|
||||||
|
<div key={notice.id} style={{
|
||||||
|
padding: '9px 10px', borderRadius: 10,
|
||||||
|
border: `1px solid ${cfg.border}`, background: cfg.bg,
|
||||||
|
marginBottom: 7, cursor: 'pointer', transition: 'all 0.18s ease',
|
||||||
|
animation: `notice-in 0.3s ${idx * 0.05}s both`,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.transform = 'translateX(2px)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.transform = 'translateX(0)')}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 16, height: 16, borderRadius: '50%',
|
||||||
|
background: `${cfg.color}22`, border: `1px solid ${cfg.color}55`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 8, color: cfg.color, flexShrink: 0, marginTop: 1, fontWeight: 700,
|
||||||
|
}}>{cfg.icon}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4 }}>
|
||||||
|
<span style={{ color: cfg.color, fontSize: 11, fontWeight: 700 }}>{notice.title}</span>
|
||||||
|
<span style={{ padding: '1px 4px', borderRadius: 4, background: 'rgba(255,255,255,0.06)', color: '#4a7fa8', fontSize: 9, flexShrink: 0 }}>{notice.tag}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: '#7aadcc', fontSize: 10, margin: '3px 0', lineHeight: 1.5, wordBreak: 'break-all' }}>{notice.desc}</p>
|
||||||
|
<span style={{ color: '#3a6080', fontSize: 9 }}>{notice.time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
// SVG path icon instead of emoji — industrial/tech feel
|
||||||
|
iconPath: string;
|
||||||
|
color: string;
|
||||||
|
accentColor: string;
|
||||||
|
status: 'active' | 'idle' | 'busy';
|
||||||
|
desc: string;
|
||||||
|
preview: string;
|
||||||
|
metrics: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const AGENTS: Agent[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '物料分析师',
|
||||||
|
role: 'Material Analyst',
|
||||||
|
iconPath: 'M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18',
|
||||||
|
color: '#00d4ff',
|
||||||
|
accentColor: '#0088aa',
|
||||||
|
status: 'active',
|
||||||
|
desc: '分析物料主数据质量与完整性',
|
||||||
|
preview: '发现3条物料规格数据缺失',
|
||||||
|
metrics: [{ label: '扫描物料', value: '245条' }, { label: '异常', value: '3项' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '采购优化师',
|
||||||
|
role: 'Procurement Optimizer',
|
||||||
|
iconPath: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||||
|
color: '#ffb340',
|
||||||
|
accentColor: '#cc8800',
|
||||||
|
status: 'busy',
|
||||||
|
desc: '优化采购路径,降低采购成本',
|
||||||
|
preview: '正在计算最优采购方案',
|
||||||
|
metrics: [{ label: '优化方案', value: '运行中' }, { label: '节省预测', value: '15%' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '账龄预警员',
|
||||||
|
role: 'Aging Monitor',
|
||||||
|
iconPath: 'M12 9v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z',
|
||||||
|
color: '#ff4d6a',
|
||||||
|
accentColor: '#aa2040',
|
||||||
|
status: 'active',
|
||||||
|
desc: '实时监控超期账龄风险物料',
|
||||||
|
preview: '检测到6种物料超120天',
|
||||||
|
metrics: [{ label: '高风险', value: '6种' }, { label: '预警阈值', value: '120天' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: '供应链顾问',
|
||||||
|
role: 'Supply Chain Advisor',
|
||||||
|
iconPath: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM12 2.24L19 6l-7 3.76L5 6l7-3.76zM3.5 7.5l8 4.36V19.5L3.5 15V7.5zm9 4.36l8-4.36V15l-8 4.5v-7.64z',
|
||||||
|
color: '#a855f7',
|
||||||
|
accentColor: '#6622bb',
|
||||||
|
status: 'idle',
|
||||||
|
desc: '分析供应商关系与合作建议',
|
||||||
|
preview: '供应商多样性评分:7.2/10',
|
||||||
|
metrics: [{ label: '供应商', value: '28家' }, { label: '评分', value: '7.2' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: '图谱助手',
|
||||||
|
role: 'Graph Intelligence',
|
||||||
|
iconPath: 'M12 3a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm-8 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm16 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm-8 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm-4-4a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm8 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2zM12 5l-8 4m8-4l8 4M4 9l4 4m12-4l-4 4M8 13l4 4m4-4l-4 4',
|
||||||
|
color: '#00e599',
|
||||||
|
accentColor: '#009966',
|
||||||
|
status: 'active',
|
||||||
|
desc: '解析知识图谱关系,揭示洞察',
|
||||||
|
preview: 'PRJ-017关联物料密度最高',
|
||||||
|
metrics: [{ label: '节点', value: '186' }, { label: '关系', value: '342' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
active: { color: '#00e599', label: '运行中' },
|
||||||
|
busy: { color: '#ffb340', label: '处理中' },
|
||||||
|
idle: { color: '#4a7fa8', label: '待机' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RightAgentPanelProps {
|
||||||
|
onAgentSelect: (agent: Agent) => void;
|
||||||
|
selectedAgentId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG icon component using stroke-based paths (Heroicons style, industrial look)
|
||||||
|
function AgentIcon({ path, color, size = 20 }: { path: string; color: string; size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size} height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d={path} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RightAgentPanel({ onAgentSelect, selectedAgentId }: RightAgentPanelProps) {
|
||||||
|
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const activeCount = AGENTS.filter(a => a.status === 'active').length;
|
||||||
|
const busyCount = AGENTS.filter(a => a.status === 'busy').length;
|
||||||
|
|
||||||
|
const BULGE = 28; // 左侧凸出量,与 LeftNoticePanel 对称
|
||||||
|
const W = 220;
|
||||||
|
|
||||||
|
// 镜像左侧面板:轮廓弧在左上角,左侧边缘向外(向左)凸出
|
||||||
|
// 顶部:右上到弧起点(70,0) → 贝塞尔弧到(0,80)
|
||||||
|
// 左侧:从(0,80) 贝塞尔向左凸出 → 到(0, 9920)
|
||||||
|
// 底部:直线到右下角
|
||||||
|
const outlinePath = `
|
||||||
|
M ${W},0
|
||||||
|
L 70,0
|
||||||
|
Q 0,0 0,80
|
||||||
|
C ${-BULGE},2000 ${-BULGE},8000 0,9920
|
||||||
|
L ${W},10000
|
||||||
|
Z
|
||||||
|
`;
|
||||||
|
const strokePath = `
|
||||||
|
M ${W},0.5
|
||||||
|
L 70,0.5
|
||||||
|
Q 0.5,0.5 0.5,80
|
||||||
|
C ${-BULGE + 0.5},2000 ${-BULGE + 0.5},8000 0.5,9920
|
||||||
|
`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// 镜像 LeftNoticePanel:左侧向外凸出
|
||||||
|
<div
|
||||||
|
className="space-animate-right"
|
||||||
|
style={{
|
||||||
|
width: W + BULGE,
|
||||||
|
height: '100%',
|
||||||
|
flexShrink: 0,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible',
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* SVG:背景填充 + 轮廓描边,完全同路径 */}
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 0, left: -BULGE,
|
||||||
|
width: `calc(100%)`, height: '100%',
|
||||||
|
pointerEvents: 'none', zIndex: 0,
|
||||||
|
overflow: 'visible',
|
||||||
|
}}
|
||||||
|
viewBox={`${-BULGE} 0 ${W + BULGE} 10000`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="rightPanelBg2" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(4,28,75,0.85)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(2,14,48,0.75)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={outlinePath} fill="url(#rightPanelBg2)" />
|
||||||
|
<path
|
||||||
|
d={strokePath}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(0,212,255,0.45)"
|
||||||
|
strokeWidth="1"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{/* 右侧竖线(轻淡) */}
|
||||||
|
<line x1={W - 0.5} y1="0" x2={W - 0.5} y2="10000"
|
||||||
|
stroke="rgba(0,212,255,0.15)" strokeWidth="1"
|
||||||
|
vectorEffect="non-scaling-stroke" />
|
||||||
|
{/* 顶部弧起点发光 */}
|
||||||
|
<circle cx="70" cy="1" r="3" fill="rgba(0,212,255,0.9)"
|
||||||
|
vectorEffect="non-scaling-stroke" />
|
||||||
|
{/* 左上弧落点发光 */}
|
||||||
|
<circle cx="0" cy="80" r="2.5" fill="rgba(0,212,255,0.6)"
|
||||||
|
vectorEffect="non-scaling-stroke" />
|
||||||
|
{/* 左侧凸出最宽处发光(中部) */}
|
||||||
|
<circle cx={-BULGE} cy="5000" r="2" fill="rgba(0,212,255,0.25)"
|
||||||
|
vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 内容层——从 BULGE 处开始,宽度 W */}
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1,
|
||||||
|
marginLeft: BULGE,
|
||||||
|
width: W,
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* 标题区 */}
|
||||||
|
<div style={{ padding: '14px 14px 10px 14px', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<span style={{ width: 3, height: 14, borderRadius: 2, background: 'linear-gradient(to bottom,#00d4ff,#0066aa)', display: 'inline-block' }} />
|
||||||
|
<span style={{ color: '#a8d8ff', fontSize: 13, fontWeight: 600, letterSpacing: '0.04em' }}>智能体</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 8, background: 'rgba(0,229,153,0.15)', color: '#00e599', fontSize: 10, fontWeight: 700 }}>
|
||||||
|
{activeCount}运行
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{busyCount > 0 && (
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 8, background: 'rgba(255,179,64,0.15)', color: '#ffb340', fontSize: 10, fontWeight: 700 }}>
|
||||||
|
{busyCount}处理
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 系统状态微型指示条 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 10, alignItems: 'center' }}>
|
||||||
|
<span style={{ color: '#2a5070', fontSize: 9, letterSpacing: '0.05em' }}>AGENT SYS</span>
|
||||||
|
<div style={{ flex: 1, height: 2, borderRadius: 1, background: 'rgba(0,212,255,0.08)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${(activeCount / AGENTS.length) * 100}%`, height: '100%', background: 'linear-gradient(to right, #00d4ff, #00e599)', borderRadius: 1 }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#00d4ff', fontSize: 9 }}>{activeCount}/{AGENTS.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'rgba(0,212,255,0.1)', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 智能体列表 */}
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px 8px 12px' }}>
|
||||||
|
{AGENTS.map((agent, idx) => {
|
||||||
|
const isSelected = selectedAgentId === agent.id;
|
||||||
|
const isHovered = hoveredId === agent.id;
|
||||||
|
const sCfg = statusConfig[agent.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
onClick={() => onAgentSelect(agent)}
|
||||||
|
onMouseEnter={() => setHoveredId(agent.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
style={{
|
||||||
|
padding: '9px 10px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: isSelected
|
||||||
|
? `1px solid ${agent.color}60`
|
||||||
|
: `1px solid ${isHovered ? agent.color + '30' : 'rgba(0,212,255,0.12)'}`,
|
||||||
|
background: isSelected
|
||||||
|
? `linear-gradient(135deg, ${agent.color}14 0%, ${agent.accentColor}08 100%)`
|
||||||
|
: isHovered
|
||||||
|
? 'rgba(0,30,70,0.6)'
|
||||||
|
: 'rgba(0,20,50,0.35)',
|
||||||
|
marginBottom: 7,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
animation: `notice-in 0.3s ${idx * 0.06}s both`,
|
||||||
|
boxShadow: isSelected ? `0 0 14px ${agent.color}20` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 顶行:图标 + 名称 + 状态点 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{/* 工业科技风图标容器 */}
|
||||||
|
<div style={{
|
||||||
|
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: `linear-gradient(135deg, ${agent.color}1a 0%, ${agent.accentColor}0d 100%)`,
|
||||||
|
border: `1px solid ${agent.color}35`,
|
||||||
|
boxShadow: isSelected ? `0 0 10px ${agent.color}30` : 'none',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* 扫描线装饰 */}
|
||||||
|
{(isSelected || isHovered) && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: '-100%',
|
||||||
|
width: '60%', height: '100%',
|
||||||
|
background: `linear-gradient(to right, transparent, ${agent.color}20, transparent)`,
|
||||||
|
animation: 'space-border-scan 1.5s linear infinite',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
<AgentIcon path={agent.iconPath} color={agent.color} size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4 }}>
|
||||||
|
<span style={{
|
||||||
|
color: isSelected ? agent.color : '#c8e4ff',
|
||||||
|
fontSize: 11, fontWeight: 700,
|
||||||
|
textShadow: isSelected ? `0 0 8px ${agent.color}80` : 'none',
|
||||||
|
}}>{agent.name}</span>
|
||||||
|
{/* 状态指示 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 6, height: 6, borderRadius: '50%',
|
||||||
|
backgroundColor: sCfg.color,
|
||||||
|
boxShadow: `0 0 5px ${sCfg.color}`,
|
||||||
|
display: 'inline-block',
|
||||||
|
animation: agent.status === 'active' ? 'space-pulse 2s ease-in-out infinite' : 'none',
|
||||||
|
}} />
|
||||||
|
<span style={{ color: sCfg.color, fontSize: 9 }}>{sCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#3a6888', fontSize: 9, letterSpacing: '0.04em' }}>{agent.role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<p style={{
|
||||||
|
color: '#6aaccc', fontSize: 10, margin: '5px 0 4px',
|
||||||
|
lineHeight: 1.4, paddingLeft: 40,
|
||||||
|
}}>{agent.desc}</p>
|
||||||
|
|
||||||
|
{/* 底部:最新动态 + 指标 */}
|
||||||
|
<div style={{ paddingLeft: 40, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4 }}>
|
||||||
|
<span style={{
|
||||||
|
color: isSelected ? agent.color + 'cc' : '#2a5a78',
|
||||||
|
fontSize: 9, flex: 1, minWidth: 0,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>▶ {agent.preview}</span>
|
||||||
|
<div style={{ display: 'flex', gap: 5, flexShrink: 0 }}>
|
||||||
|
{agent.metrics.map(m => (
|
||||||
|
<div key={m.label} style={{
|
||||||
|
padding: '1px 5px', borderRadius: 4,
|
||||||
|
background: `${agent.color}10`,
|
||||||
|
border: `1px solid ${agent.color}25`,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: agent.color, fontSize: 9, fontWeight: 700 }}>{m.value}</div>
|
||||||
|
<div style={{ color: '#2a5070', fontSize: 8 }}>{m.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Agent };
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import ThemeToggle from '@/shared/components/ThemeToggle';
|
||||||
|
|
||||||
|
export default function SpaceHeader() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [time, setTime] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const timeStr = `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`;
|
||||||
|
const dateStr = `${time.getFullYear()}-${pad(time.getMonth()+1)}-${pad(time.getDate())}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
height: 90,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 24px',
|
||||||
|
borderBottom: '1px solid rgba(0,212,255,0.25)',
|
||||||
|
background: 'rgba(2,10,28,0.45)',
|
||||||
|
backdropFilter: 'blur(32px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(32px) saturate(180%)',
|
||||||
|
boxShadow: '0 1px 0 rgba(0,212,255,0.12), 0 8px 32px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 顶部扫光线 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: 1,
|
||||||
|
background: 'linear-gradient(90deg, transparent 0%, rgba(0,212,255,0.4) 20%, rgba(0,212,255,1) 50%, rgba(0,212,255,0.4) 80%, transparent 100%)',
|
||||||
|
boxShadow: '0 0 12px rgba(0,212,255,0.8)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 底部内发光线 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 0, left: 0, right: 0, height: 1,
|
||||||
|
background: 'linear-gradient(90deg, transparent 0%, rgba(0,212,255,0.08) 30%, rgba(0,212,255,0.15) 50%, rgba(0,212,255,0.08) 70%, transparent 100%)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Logo + 标题 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 44, height: 44, borderRadius: 12,
|
||||||
|
background: 'linear-gradient(135deg, rgba(255,255,255,0.6) 0%, rgba(0,255,255,0.8) 100%)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
border: '1px solid rgba(0,212,255,0.4)',
|
||||||
|
boxShadow: '0 0 16px rgba(0,212,255,0.25), inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}>
|
||||||
|
<img src="/ology-logo.png" alt="OLOGY" style={{ width: 35, height: 'auto', objectFit: 'contain' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<h1 style={{
|
||||||
|
color: '#e8f4ff', fontSize: 20, fontWeight: 700,
|
||||||
|
letterSpacing: '0.06em', margin: 0,
|
||||||
|
textShadow: '0 0 24px rgba(0,212,255,0.7), 0 0 48px rgba(0,212,255,0.3)',
|
||||||
|
}}>
|
||||||
|
采购域知识图谱
|
||||||
|
</h1>
|
||||||
|
{/* 状态指示 */}
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '2px 8px', borderRadius: 10,
|
||||||
|
background: 'rgba(0,212,255,0.08)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.35)',
|
||||||
|
boxShadow: '0 0 8px rgba(0,212,255,0.15)',
|
||||||
|
fontSize: 13, color: '#00d4ff', fontWeight: 600, letterSpacing: '0.06em',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 5, height: 5, borderRadius: '50%',
|
||||||
|
backgroundColor: '#00ff88',
|
||||||
|
boxShadow: '0 0 8px #00ff88, 0 0 16px rgba(0,255,136,0.4)',
|
||||||
|
animation: 'space-pulse 2s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'rgba(100,180,220,0.7)', fontSize: 13, margin: 0, letterSpacing: '0.04em' }}>
|
||||||
|
Powered by EIOS
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 左分隔线 */}
|
||||||
|
<div style={{ width: 1, height: 48, background: 'linear-gradient(to bottom, transparent, rgba(0,212,255,0.2), transparent)', flexShrink: 0, marginRight: 24 }} />
|
||||||
|
|
||||||
|
{/* 中部统计区 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, justifyContent: 'center' }}>
|
||||||
|
{[
|
||||||
|
{ label: '物料总数', value: '20' },
|
||||||
|
{ label: '项目数量', value: '14' },
|
||||||
|
{ label: '账龄预警', value: '6' },
|
||||||
|
].map((item, idx) => (
|
||||||
|
<div key={item.label} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '8px 20px',
|
||||||
|
borderRadius: 10,
|
||||||
|
background: 'rgba(0,212,255,0.05)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.12)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.04)',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#00d4ff', fontSize: 22, fontWeight: 700, lineHeight: 1, fontVariantNumeric: 'tabular-nums', textShadow: '0 0 12px rgba(0,212,255,0.6)' }}>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(100,160,200,0.7)', fontSize: 12, marginTop: 3, letterSpacing: '0.04em' }}>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{idx < 2 && <div style={{ width: 1, height: 32, background: 'rgba(0,212,255,0.12)', margin: '0 4px' }} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右分隔线 */}
|
||||||
|
<div style={{ width: 1, height: 48, background: 'linear-gradient(to bottom, transparent, rgba(0,212,255,0.2), transparent)', flexShrink: 0, marginLeft: 24 }} />
|
||||||
|
|
||||||
|
{/* 右侧时间 + 切换按钮 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, justifyContent: 'flex-end' }}>
|
||||||
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '6px 12px', borderRadius: 8,
|
||||||
|
border: '1px solid rgba(168,216,255,0.2)',
|
||||||
|
background: 'rgba(168,216,255,0.06)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
color: '#a8d8ff',
|
||||||
|
fontSize: 13, fontWeight: 500, cursor: 'pointer', letterSpacing: '0.02em',
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(0,212,255,0.12)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(0,212,255,0.4)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(168,216,255,0.06)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(168,216,255,0.2)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← 返回平台
|
||||||
|
</button>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{
|
||||||
|
color: '#00d4ff', fontSize: 22, fontWeight: 700,
|
||||||
|
fontVariantNumeric: 'tabular-nums', lineHeight: 1,
|
||||||
|
textShadow: '0 0 16px rgba(0,212,255,0.7)',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
{timeStr}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(100,160,200,0.7)', fontSize: 12, marginTop: 2, letterSpacing: '0.04em' }}>
|
||||||
|
{dateStr}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/knowledge-graph')}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '8px 16px', borderRadius: 8,
|
||||||
|
border: '1px solid rgba(168,216,255,0.2)',
|
||||||
|
background: 'rgba(168,216,255,0.06)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
color: '#a8d8ff',
|
||||||
|
fontSize: 14, fontWeight: 500, cursor: 'pointer', letterSpacing: '0.02em',
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.05)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(0,212,255,0.12)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(0,212,255,0.4)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 0 12px rgba(0,212,255,0.15), inset 0 1px 0 rgba(255,255,255,0.05)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.background = 'rgba(168,216,255,0.06)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(168,216,255,0.2)';
|
||||||
|
e.currentTarget.style.boxShadow = 'inset 0 1px 0 rgba(255,255,255,0.05)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← 经典视图
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
interface Star {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
opacity: number;
|
||||||
|
delay: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StarBackground() {
|
||||||
|
const stars = useMemo<Star[]>(() => {
|
||||||
|
const arr: Star[] = [];
|
||||||
|
// 小星点 200 个
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
arr.push({
|
||||||
|
id: i,
|
||||||
|
x: Math.random() * 100,
|
||||||
|
y: Math.random() * 100,
|
||||||
|
size: Math.random() * 1.5 + 0.5,
|
||||||
|
opacity: Math.random() * 0.6 + 0.2,
|
||||||
|
delay: Math.random() * 4,
|
||||||
|
duration: Math.random() * 14 + 6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 亮星点 30 个
|
||||||
|
for (let i = 200; i < 230; i++) {
|
||||||
|
arr.push({
|
||||||
|
id: i,
|
||||||
|
x: Math.random() * 100,
|
||||||
|
y: Math.random() * 100,
|
||||||
|
size: Math.random() * 2 + 1.5,
|
||||||
|
opacity: Math.random() * 0.4 + 0.5,
|
||||||
|
delay: Math.random() * 5,
|
||||||
|
duration: Math.random() * 4 + 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'radial-gradient(ellipse at 20% 30%, #051840 0%, #020d1a 40%, #010810 100%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 星云效果层 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
'radial-gradient(ellipse 60% 40% at 70% 20%, rgba(0,80,180,0.12) 0%, transparent 70%),' +
|
||||||
|
'radial-gradient(ellipse 40% 30% at 20% 80%, rgba(0,160,255,0.08) 0%, transparent 60%),' +
|
||||||
|
'radial-gradient(ellipse 50% 35% at 50% 50%, rgba(0,30,80,0.15) 0%, transparent 80%)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 星点 SVG */}
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ position: 'absolute', inset: 0 }}
|
||||||
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
viewBox="0 0 1000 700"
|
||||||
|
>
|
||||||
|
{stars.map(star => (
|
||||||
|
<circle
|
||||||
|
key={star.id}
|
||||||
|
cx={star.x * 10}
|
||||||
|
cy={star.y * 7}
|
||||||
|
r={star.size}
|
||||||
|
fill="white"
|
||||||
|
style={{
|
||||||
|
opacity: star.opacity,
|
||||||
|
animation: `star-twinkle ${star.duration}s ${star.delay}s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 底部渐变遮罩,让底部聊天栏融合 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 120,
|
||||||
|
background: 'linear-gradient(to top, rgba(1,8,16,0.6) 0%, transparent 100%)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
export interface Material {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
spec: string;
|
||||||
|
qty: number;
|
||||||
|
projectId: string;
|
||||||
|
aging: string;
|
||||||
|
prefix: string;
|
||||||
|
category: string;
|
||||||
|
categoryCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
materialCount: number;
|
||||||
|
agingRange: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: 'material' | 'project' | 'category' | 'aging';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
data?: Material | Project;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rawMaterials: Material[] = [
|
||||||
|
{ id: 'JY-1001-004-001', name: '温度传感器', spec: 'PT100,卫生级,插入深度100mm,4-20mA输出', qty: 1, projectId: '12098034002', aging: '60-90', prefix: 'JY', category: '温控系统', categoryCode: '004' },
|
||||||
|
{ id: 'JT-1001-004-0102', name: '电加热套', spec: '功率2kW,220V AC,带硅酸铝保温层,耐温200℃', qty: 1, projectId: '12098034015', aging: '60-90', prefix: 'JT', category: '温控系统', categoryCode: '004' },
|
||||||
|
{ id: 'JY-1001-004-003', name: '温控模块', spec: 'PID控制,4-20mA输入,继电器输出,24V DC', qty: 1, projectId: '12098034015', aging: '60-90', prefix: 'JY', category: '温控系统', categoryCode: '004' },
|
||||||
|
{ id: 'JY-1003-004-004', name: '温度变送器', spec: 'PT100输入,4-20mA输出,24V DC,精度0.2级', qty: 1, projectId: '12098034015', aging: '60-90', prefix: 'JY', category: '温控系统', categoryCode: '004' },
|
||||||
|
{ id: 'JY-1001-005-001', name: '氮气瓶', spec: '40L,高纯氮气,压力15MPa,钢制', qty: 1, projectId: '12098034006', aging: '60-90', prefix: 'JY', category: '气路系统', categoryCode: '005' },
|
||||||
|
{ id: 'JY-1004-005-022', name: '氮气减压阀', spec: '进口15MPa,出口0~0.6MPa,带双压力表,304不锈钢', qty: 1, projectId: '12098034017', aging: '90-120', prefix: 'JY', category: '气路系统', categoryCode: '005' },
|
||||||
|
{ id: 'JO-1005-005-043', name: '气体流量控制器', spec: '量程0~10L/min,4-20mA输出,24V DC,精度1级', qty: 1, projectId: '12098034017', aging: '90-120', prefix: 'JO', category: '气路系统', categoryCode: '005' },
|
||||||
|
{ id: 'JY-1001-004-006', name: '保温棉', spec: '硅酸铝,厚度50mm,耐温1200℃', qty: 2, projectId: '12098034029', aging: '90-120', prefix: 'JY', category: '温控系统', categoryCode: '004' },
|
||||||
|
{ id: 'JY-1001-004-077', name: '铝箔胶带', spec: '宽度50mm,长度50m,耐温200℃', qty: 1, projectId: '12098034010', aging: '90-120', prefix: 'JY', category: '温控系统', categoryCode: '004' },
|
||||||
|
{ id: 'JU-1001-005-005', name: '气体过滤器', spec: '精度0.2μm,卫生级,304不锈钢', qty: 1, projectId: '12098034030', aging: '90-120', prefix: 'JU', category: '气路系统', categoryCode: '005' },
|
||||||
|
{ id: 'JY-1001-005-061', name: '单向阀', spec: 'DN15,卫生级,304不锈钢,开启压力0.02MPa', qty: 1, projectId: '12098034012', aging: '90-120', prefix: 'JY', category: '气路系统', categoryCode: '005' },
|
||||||
|
{ id: 'JP-1001-005-007', name: '气体压力表', spec: '量程0~1MPa,卫生级快装接口,304不锈钢', qty: 1, projectId: '12098034031', aging: '90-120', prefix: 'JP', category: '气路系统', categoryCode: '005' },
|
||||||
|
{ id: 'JY-1001-006-009', name: '密封垫片', spec: 'DN50,食品级硅胶,厚度3mm', qty: 2, projectId: '12098034044', aging: '90-120', prefix: 'JY', category: '阀件系统', categoryCode: '006' },
|
||||||
|
{ id: 'JY-1001-006-016', name: '限位开关', spec: '24V DC,PNP输出,防爆型,IP65', qty: 2, projectId: '12098034015', aging: '90-120', prefix: 'JY', category: '阀件系统', categoryCode: '006' },
|
||||||
|
{ id: 'JO-1001-006-057', name: '气源处理三联件', spec: 'AC2000,24V DC,带过滤、减压、油雾器', qty: 1, projectId: '12098034017', aging: '120-150', prefix: 'JO', category: '阀件系统', categoryCode: '006' },
|
||||||
|
{ id: 'JY-1001-006-001', name: '气动蝶阀', spec: 'DN50,卫生级,304不锈钢,双作用,压力0.4~0.6MPa', qty: 1, projectId: '12098034017', aging: '120-150', prefix: 'JY', category: '阀件系统', categoryCode: '006' },
|
||||||
|
{ id: 'JY-1001-006-002', name: '阀门定位器', spec: '4-20mA输入,24V DC,防爆型,IP65', qty: 1, projectId: '12098034028', aging: '120-150', prefix: 'JY', category: '阀件系统', categoryCode: '006' },
|
||||||
|
{ id: 'JP-1001-006-033', name: '电磁阀', spec: '24V DC,5/2通,防爆型,IP65', qty: 1, projectId: '12098034019', aging: '120-150', prefix: 'JP', category: '阀件系统', categoryCode: '006' },
|
||||||
|
{ id: 'JY-1001-007-005', name: '防滑脚垫', spec: '橡胶,100*100*20mm,耐油耐磨', qty: 4, projectId: '12098034025', aging: '120-150', prefix: 'JY', category: '结构件', categoryCode: '007' },
|
||||||
|
{ id: 'JE-1001-007-016', name: '平台安装螺栓', spec: '304不锈钢,M12*50,全螺纹', qty: 16, projectId: '12098034021', aging: '120-150', prefix: 'JE', category: '结构件', categoryCode: '007' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const projectMap: Record<string, { name: string; desc: string }> = {
|
||||||
|
'12098034002': { name: 'PRJ-002', desc: '温度监测系统' },
|
||||||
|
'12098034006': { name: 'PRJ-006', desc: '气体供应系统' },
|
||||||
|
'12098034010': { name: 'PRJ-010', desc: '保温隔热工程' },
|
||||||
|
'12098034012': { name: 'PRJ-012', desc: '流体控制系统' },
|
||||||
|
'12098034015': { name: 'PRJ-015', desc: '综合控制模块' },
|
||||||
|
'12098034017': { name: 'PRJ-017', desc: '气动控制系统' },
|
||||||
|
'12098034019': { name: 'PRJ-019', desc: '电气控制模块' },
|
||||||
|
'12098034021': { name: 'PRJ-021', desc: '机械结构安装' },
|
||||||
|
'12098034025': { name: 'PRJ-025', desc: '平台防护工程' },
|
||||||
|
'12098034028': { name: 'PRJ-028', desc: '阀门定位系统' },
|
||||||
|
'12098034029': { name: 'PRJ-029', desc: '保温材料采购' },
|
||||||
|
'12098034030': { name: 'PRJ-030', desc: '过滤净化系统' },
|
||||||
|
'12098034031': { name: 'PRJ-031', desc: '压力监测系统' },
|
||||||
|
'12098034044': { name: 'PRJ-044', desc: '密封件采购' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const categories = ['温控系统', '气路系统', '阀件系统', '结构件'];
|
||||||
|
|
||||||
|
export const agingColors: Record<string, string> = {
|
||||||
|
'60-90': '#34d399',
|
||||||
|
'90-120': '#fbbf24',
|
||||||
|
'120-150': '#f87171',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const categoryColors: Record<string, string> = {
|
||||||
|
'温控系统': '#22d3ee',
|
||||||
|
'气路系统': '#a78bfa',
|
||||||
|
'阀件系统': '#fb923c',
|
||||||
|
'结构件': '#4ade80',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prefixLabels: Record<string, string> = {
|
||||||
|
'JY': '通用物料',
|
||||||
|
'JT': '特殊设备',
|
||||||
|
'JO': '仪表控制',
|
||||||
|
'JP': '压力仪表',
|
||||||
|
'JU': '过滤元件',
|
||||||
|
'JE': '标准件',
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ModuleConfig } from '../registry';
|
||||||
|
|
||||||
|
export const moduleConfig: ModuleConfig = {
|
||||||
|
id: 'knowledge-graph',
|
||||||
|
name: '采购域知识图谱',
|
||||||
|
description: '采购域知识图谱可视化,物料关系与账龄分析',
|
||||||
|
icon: 'Network',
|
||||||
|
path: '/knowledge-graph',
|
||||||
|
accentColor: '#0A7AFF',
|
||||||
|
accentColorLight: '#0A7AFF',
|
||||||
|
gradient: 'linear-gradient(135deg, #0a1628, #1a2a4a, #2a3a5a)',
|
||||||
|
gradientLight: 'linear-gradient(135deg, #eff6ff, #dbeafe, #bfdbfe)',
|
||||||
|
tags: ['知识图谱', '采购', '可视化'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import ThemeToggle from '@/shared/components/ThemeToggle';
|
||||||
|
import KnowledgeGraph from '../components/KnowledgeGraph';
|
||||||
|
import NodeDetailPanel from '../components/NodeDetailPanel';
|
||||||
|
import StatsOverview from '../components/StatsOverview';
|
||||||
|
import MaterialList from '../components/MaterialList';
|
||||||
|
import type { Material } from '../data/materials';
|
||||||
|
|
||||||
|
type ViewMode = 'category' | 'project' | 'aging';
|
||||||
|
type SideTab = 'stats' | 'list' | 'assistant';
|
||||||
|
|
||||||
|
interface SelectedNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
color: string;
|
||||||
|
material?: Material;
|
||||||
|
count?: number;
|
||||||
|
agingRange?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBAR_MIN = 240;
|
||||||
|
const SIDEBAR_MAX = 600;
|
||||||
|
|
||||||
|
export default function KnowledgeGraphHome() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('category');
|
||||||
|
const [filterCategory, setFilterCategory] = useState<string | null>(null);
|
||||||
|
const [filterAging, setFilterAging] = useState<string | null>(null);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<SelectedNode | null>(null);
|
||||||
|
const [sideTab, setSideTab] = useState<SideTab>('stats');
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(320);
|
||||||
|
const isDragging = useRef(false);
|
||||||
|
|
||||||
|
const handleNodeSelect = (node: SelectedNode | null) => setSelectedNode(node);
|
||||||
|
const handleMaterialSelect = (mat: Material | null) => {
|
||||||
|
if (!mat) { setSelectedNode(null); return; }
|
||||||
|
setSelectedNode({ id: mat.id, label: mat.name, type: 'material', color: '#0A7AFF', material: mat });
|
||||||
|
};
|
||||||
|
const handleFilterCategory = (c: string | null) => { setFilterCategory(c); setSelectedNode(null); };
|
||||||
|
const handleFilterAging = (a: string | null) => { setFilterAging(a); setSelectedNode(null); };
|
||||||
|
|
||||||
|
const handleDividerMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging.current = true;
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startWidth = sidebarWidth;
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
const onMouseMove = (ev: MouseEvent) => {
|
||||||
|
if (!isDragging.current) return;
|
||||||
|
const delta = ev.clientX - startX;
|
||||||
|
const newWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startWidth + delta));
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
};
|
||||||
|
const onMouseUp = () => {
|
||||||
|
isDragging.current = false;
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
document.addEventListener('mouseup', onMouseUp);
|
||||||
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
|
const viewLabels: Record<ViewMode, string> = { category: '按分类', project: '按项目', aging: '按账龄' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen overflow-hidden" style={{ backgroundColor:'#f5f5f7' }}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="flex-shrink-0 flex flex-col sidebar-bg" style={{ width:sidebarWidth, borderRight:'none' }}>
|
||||||
|
{/* Logo header */}
|
||||||
|
<div style={{ padding:'20px 20px 16px' }}>
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:14 }}>
|
||||||
|
<div style={{ width:52, height:52, borderRadius:14, backgroundColor:'#fff', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0, boxShadow:'0 3px 12px rgba(26,58,92,0.28)' }}>
|
||||||
|
<img src="/ology-logo.png" alt="OLOGY 欧力杰" style={{ width:46, height:'auto', objectFit:'contain' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ color:'#1c1c1e', fontSize:17, fontWeight:700, letterSpacing:'-0.03em', lineHeight:1.2, margin:0 }}>采购域知识图谱</h1>
|
||||||
|
<p style={{ color:'#8e8e93', fontSize:13, marginTop:4, letterSpacing:'-0.01em' }}>采购单关系可视化</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height:1, backgroundColor:'#e5e5ea', margin:'0 20px' }} />
|
||||||
|
|
||||||
|
{/* Segmented tab */}
|
||||||
|
<div style={{ padding:'12px 16px' }}>
|
||||||
|
<div style={{ display:'flex', borderRadius:10, padding:3, gap:2, backgroundColor:'#ebebf0' }}>
|
||||||
|
{(['stats','list','assistant'] as SideTab[]).map(tab => (
|
||||||
|
<button key={tab} onClick={() => setSideTab(tab)}
|
||||||
|
style={{
|
||||||
|
flex:1, padding:'8px 0', borderRadius:8,
|
||||||
|
fontSize:14, fontWeight:500, letterSpacing:'-0.01em', cursor:'pointer',
|
||||||
|
backgroundColor: sideTab===tab ? '#ffffff' : 'transparent',
|
||||||
|
color: sideTab===tab ? '#1c1c1e' : '#8e8e93',
|
||||||
|
boxShadow: sideTab===tab ? '0 1px 4px rgba(0,0,0,0.12)' : 'none',
|
||||||
|
transition:'all 0.18s ease',
|
||||||
|
border:'none',
|
||||||
|
}}>
|
||||||
|
{tab==='stats' ? '统计概览' : tab==='list' ? '物料列表' : '物料助手'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex:1, overflow:'hidden' }}>
|
||||||
|
{sideTab==='stats' ? (
|
||||||
|
<div style={{ padding:'0 16px 16px', height:'100%', overflowY:'auto' }}>
|
||||||
|
<StatsOverview filterCategory={filterCategory} filterAging={filterAging}
|
||||||
|
onFilterCategory={handleFilterCategory} onFilterAging={handleFilterAging} />
|
||||||
|
</div>
|
||||||
|
) : sideTab==='list' ? (
|
||||||
|
<MaterialList filterCategory={filterCategory} filterAging={filterAging}
|
||||||
|
onSelect={handleMaterialSelect} selectedId={selectedNode?.id || null} />
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
src="https://kai-skc.apps.digiwincloud.com.cn/assistant/web/ncqomq10wgh338c9"
|
||||||
|
style={{ width:'100%', height:'100%', border:'none' }}
|
||||||
|
title="物料助手"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Resizable Divider */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleDividerMouseDown}
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
position: 'relative',
|
||||||
|
flexShrink: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0, bottom: 0, left: 2, right: 2,
|
||||||
|
backgroundColor: '#e5e5ea',
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: 'background-color 0.18s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#007AFF'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.backgroundColor = '#e5e5ea'; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<main style={{ flex:1, display:'flex', flexDirection:'column', minWidth:0 }}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{
|
||||||
|
height:60, flexShrink:0, display:'flex', alignItems:'center', gap:16, padding:'0 24px',
|
||||||
|
backgroundColor:'rgba(248,248,250,0.92)', backdropFilter:'blur(20px)',
|
||||||
|
borderBottom:'1px solid #e5e5ea',
|
||||||
|
}}>
|
||||||
|
{/* View mode */}
|
||||||
|
<div style={{ display:'flex', borderRadius:10, padding:3, gap:2, backgroundColor:'#ebebf0' }}>
|
||||||
|
{(Object.keys(viewLabels) as ViewMode[]).map(mode => (
|
||||||
|
<button key={mode} onClick={() => { setViewMode(mode); setSelectedNode(null); }}
|
||||||
|
style={{
|
||||||
|
padding:'7px 18px', borderRadius:8,
|
||||||
|
fontSize:14, fontWeight:500, letterSpacing:'-0.01em', cursor:'pointer',
|
||||||
|
backgroundColor: viewMode===mode ? '#ffffff' : 'transparent',
|
||||||
|
color: viewMode===mode ? '#007AFF' : '#8e8e93',
|
||||||
|
boxShadow: viewMode===mode ? '0 1px 4px rgba(0,0,0,0.12)' : 'none',
|
||||||
|
transition:'all 0.18s ease',
|
||||||
|
border:'none',
|
||||||
|
}}>
|
||||||
|
{viewLabels[mode]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters */}
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:8, flex:1 }}>
|
||||||
|
{filterCategory && (
|
||||||
|
<span style={{ display:'inline-flex', alignItems:'center', gap:6, padding:'5px 12px', borderRadius:20, fontSize:13, fontWeight:500, backgroundColor:'rgba(10,122,255,0.08)', color:'#0A7AFF', border:'1px solid rgba(10,122,255,0.18)' }}>
|
||||||
|
{filterCategory}
|
||||||
|
<button onClick={() => setFilterCategory(null)} style={{ width:16, height:16, borderRadius:'50%', backgroundColor:'rgba(10,122,255,0.18)', display:'flex', alignItems:'center', justifyContent:'center', fontSize:12, lineHeight:1, border:'none', cursor:'pointer', color:'#0A7AFF' }}>×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{filterAging && (
|
||||||
|
<span style={{ display:'inline-flex', alignItems:'center', gap:6, padding:'5px 12px', borderRadius:20, fontSize:13, fontWeight:500, backgroundColor:'rgba(224,140,0,0.08)', color:'#C07800', border:'1px solid rgba(224,140,0,0.20)' }}>
|
||||||
|
账龄 {filterAging} 天
|
||||||
|
<button onClick={() => setFilterAging(null)} style={{ width:16, height:16, borderRadius:'50%', backgroundColor:'rgba(224,140,0,0.20)', display:'flex', alignItems:'center', justifyContent:'center', fontSize:12, lineHeight:1, border:'none', cursor:'pointer', color:'#C07800' }}>×</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(filterCategory || filterAging) && (
|
||||||
|
<button onClick={() => { setFilterCategory(null); setFilterAging(null); }}
|
||||||
|
style={{ fontSize:13, color:'#8e8e93', border:'none', cursor:'pointer', backgroundColor:'transparent', letterSpacing:'-0.01em' }}>
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize:13, color:'#aeaeb2', letterSpacing:'-0.01em', whiteSpace:'nowrap' }}>
|
||||||
|
点击节点查看详情 · 滚轮缩放 · 拖拽平移
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ThemeToggle />
|
||||||
|
<button onClick={() => navigate('/')} style={{
|
||||||
|
display:'flex', alignItems:'center', gap:4,
|
||||||
|
padding:'6px 12px', borderRadius:8, cursor:'pointer',
|
||||||
|
backgroundColor:'transparent',
|
||||||
|
border:'1px solid #e5e5ea',
|
||||||
|
color:'#8e8e93', fontSize:13, fontWeight:500, letterSpacing:'-0.01em',
|
||||||
|
transition:'all 0.18s ease', whiteSpace:'nowrap',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(0,122,255,0.3)'; e.currentTarget.style.color = '#007AFF'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e5ea'; e.currentTarget.style.color = '#8e8e93'; }}
|
||||||
|
>
|
||||||
|
← 返回平台
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 进入EIOS按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/knowledge-graph/space')}
|
||||||
|
style={{
|
||||||
|
display:'flex', alignItems:'center', gap:6,
|
||||||
|
padding:'8px 16px', borderRadius:10, cursor:'pointer',
|
||||||
|
backgroundColor:'#ffffff',
|
||||||
|
border:'1px solid #e5e5ea',
|
||||||
|
color:'#007AFF', fontSize:13, fontWeight:600, letterSpacing:'-0.01em',
|
||||||
|
boxShadow:'0 1px 4px rgba(0,0,0,0.08)', whiteSpace:'nowrap',
|
||||||
|
transition:'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#f0f7ff';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(0,122,255,0.3)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,122,255,0.12)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#ffffff';
|
||||||
|
e.currentTarget.style.borderColor = '#e5e5ea';
|
||||||
|
e.currentTarget.style.boxShadow = '0 1px 4px rgba(0,0,0,0.08)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#007AFF" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||||
|
{/* 六边形外框 */}
|
||||||
|
<path d="M12 2l8 4.5v9L12 20l-8-4.5v-9L12 2z" />
|
||||||
|
{/* 中心节点 */}
|
||||||
|
<circle cx="12" cy="11.5" r="2" />
|
||||||
|
{/* 三条辐射线 */}
|
||||||
|
<line x1="12" y1="9.5" x2="12" y2="6" />
|
||||||
|
<line x1="10.27" y1="10.5" x2="7" y2="8.7" />
|
||||||
|
<line x1="13.73" y1="10.5" x2="17" y2="8.7" />
|
||||||
|
</svg> 进入EIOS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graph + detail */}
|
||||||
|
<div style={{ flex:1, display:'flex', overflow:'hidden', padding:16, gap:12 }}>
|
||||||
|
<div style={{ flex:1, minWidth:0, borderRadius:20, overflow:'hidden', backgroundColor:'#ffffff', boxShadow:'0 2px 20px rgba(0,0,0,0.06)', border:'1px solid #e5e5ea' }}>
|
||||||
|
<KnowledgeGraph
|
||||||
|
onNodeSelect={handleNodeSelect}
|
||||||
|
filterCategory={filterCategory}
|
||||||
|
filterAging={filterAging}
|
||||||
|
viewMode={viewMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedNode && (
|
||||||
|
<div style={{ width:320, flexShrink:0 }} className="animate-slide-in-right">
|
||||||
|
<NodeDetailPanel node={selectedNode} onClose={() => setSelectedNode(null)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import StarBackground from '../components/space/StarBackground';
|
||||||
|
import SpaceHeader from '../components/space/SpaceHeader';
|
||||||
|
import LeftNoticePanel from '../components/space/LeftNoticePanel';
|
||||||
|
import RightAgentPanel, { type Agent } from '../components/space/RightAgentPanel';
|
||||||
|
import BottomChatBar from '../components/space/BottomChatBar';
|
||||||
|
import CenterGraphArea, { type SelectedNode } from '../components/space/CenterGraphArea';
|
||||||
|
import NodeDetailPanel from '../components/NodeDetailPanel';
|
||||||
|
|
||||||
|
export default function SpaceCockpit() {
|
||||||
|
const [selectedNode, setSelectedNode] = useState<SelectedNode | null>(null);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||||
|
|
||||||
|
const handleAgentSelect = (agent: Agent) =>
|
||||||
|
setSelectedAgent(prev => prev?.id === agent.id ? null : agent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100vw', height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: "-apple-system, 'SF Pro Text', 'PingFang SC', sans-serif",
|
||||||
|
color: '#a8d8ff',
|
||||||
|
}}>
|
||||||
|
{/* 星空背景 */}
|
||||||
|
<StarBackground />
|
||||||
|
|
||||||
|
{/* 内容层 */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
|
||||||
|
{/* 顶部标题栏 */}
|
||||||
|
<SpaceHeader />
|
||||||
|
|
||||||
|
{/* Header 与中部区域之间的留白 */}
|
||||||
|
<div style={{ height: 14, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 中部区域:左面板 + 中央 + 右面板 */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', minHeight: 0, position: 'relative', gap: 0, padding: '0 0' }}>
|
||||||
|
|
||||||
|
{/* 左侧弧形面板——顶部留出 80px,高度延伸至底部 */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flexShrink: 0, marginTop: 80, height: '100%' }}>
|
||||||
|
<LeftNoticePanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 左面板与中央的空隙 */}
|
||||||
|
<div style={{ width: 16, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 中央图谱区 + 节点详情,顶部与左右面板对齐 */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', position: 'relative', marginTop: 36 }}>
|
||||||
|
<CenterGraphArea onNodeSelect={setSelectedNode} />
|
||||||
|
|
||||||
|
{/* 节点详情浮层 */}
|
||||||
|
{selectedNode && (
|
||||||
|
<div
|
||||||
|
className="space-animate-right"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', right: 8, top: 8, bottom: 8,
|
||||||
|
width: 280, zIndex: 20,
|
||||||
|
borderRadius: 14,
|
||||||
|
border: '1px solid rgba(0,212,255,0.25)',
|
||||||
|
boxShadow: '0 0 24px rgba(0,212,255,0.1)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'rgba(0,15,45,0.85)',
|
||||||
|
backdropFilter: 'blur(16px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NodeDetailPanel node={selectedNode} onClose={() => setSelectedNode(null)} darkMode={true} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中央与右面板的空隙 */}
|
||||||
|
<div style={{ width: 40, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 右侧智能体面板——顶部同样留出 80px,高度延伸至底部 */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', flexShrink: 0, marginTop: 80, height: '100%' }}>
|
||||||
|
<RightAgentPanel
|
||||||
|
onAgentSelect={handleAgentSelect}
|
||||||
|
selectedAgentId={selectedAgent?.id ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部与中部之间留白 */}
|
||||||
|
<div style={{ height: 14, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 底部椭圆弧形对话框(左右留白,不全屏宽) */}
|
||||||
|
<BottomChatBar activeAgentName={selectedAgent?.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { DEMANDS, statusConfig } from '../data/demands';
|
||||||
|
|
||||||
|
export default function AIMHeader() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [time, setTime] = useState(new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setTime(new Date()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const timeStr = `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`;
|
||||||
|
const dateStr = `${time.getFullYear()}-${pad(time.getMonth()+1)}-${pad(time.getDate())}`;
|
||||||
|
|
||||||
|
const totalDemands = 93;
|
||||||
|
const matchingCount = DEMANDS.filter(d => d.status === 'matching' || d.status === 'matched').length;
|
||||||
|
const pilotCount = DEMANDS.filter(d => d.status === 'pilot' || d.status === 'deployed').length;
|
||||||
|
const newCount = DEMANDS.filter(d => d.status === 'new').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header style={{
|
||||||
|
height: 88,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 24px',
|
||||||
|
background: 'transparent',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 10,
|
||||||
|
overflow: 'visible',
|
||||||
|
}}>
|
||||||
|
{/* 顶部扫光线 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: 1,
|
||||||
|
background: 'linear-gradient(90deg, transparent 0%, rgba(0,212,255,0.3) 20%, rgba(0,212,255,0.9) 50%, rgba(0,212,255,0.3) 80%, transparent 100%)',
|
||||||
|
boxShadow: '0 0 12px rgba(0,212,255,0.6)',
|
||||||
|
zIndex: 2,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 底部弧形 */}
|
||||||
|
<svg style={{
|
||||||
|
position: 'absolute', bottom: -26, left: 0,
|
||||||
|
width: '100%', height: 28,
|
||||||
|
pointerEvents: 'none', zIndex: 0, overflow: 'visible',
|
||||||
|
}} viewBox="0 0 1000 28" preserveAspectRatio="none">
|
||||||
|
<path d="M 0,0 C 250,28 750,28 1000,0" fill="none" stroke="rgba(0,212,255,0.45)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<path d="M 0,0 C 250,28 750,28 1000,0" fill="none" stroke="rgba(0,212,255,0.1)" strokeWidth="6" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="0" cy="0" r="2.5" fill="rgba(0,212,255,0.6)" />
|
||||||
|
<circle cx="1000" cy="0" r="2.5" fill="rgba(0,212,255,0.6)" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Logo + 标题 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flex: 1, position: 'relative', zIndex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 46, height: 46, borderRadius: 12,
|
||||||
|
background: 'linear-gradient(135deg, rgba(0,80,180,0.7) 0%, rgba(0,30,80,0.9) 100%)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
border: '1px solid rgba(0,212,255,0.5)',
|
||||||
|
boxShadow: '0 0 20px rgba(0,212,255,0.3), inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<img src="/wenzhou-city-logo.png" alt="温州城市Logo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<h1 style={{
|
||||||
|
color: '#e8f4ff', fontSize: 19, fontWeight: 700,
|
||||||
|
letterSpacing: '0.05em', margin: 0,
|
||||||
|
textShadow: '0 0 24px rgba(0,212,255,0.7), 0 0 48px rgba(0,212,255,0.3)',
|
||||||
|
}}>
|
||||||
|
温州市“AI+制造”智创空间
|
||||||
|
</h1>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '2px 8px', borderRadius: 10,
|
||||||
|
background: 'rgba(0,212,255,0.08)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.35)',
|
||||||
|
fontSize: 12, color: '#00d4ff', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 5, height: 5, borderRadius: '50%',
|
||||||
|
backgroundColor: '#00ff88',
|
||||||
|
boxShadow: '0 0 8px #00ff88',
|
||||||
|
animation: 'space-pulse 2s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 12, margin: 0, letterSpacing: '0.04em', lineHeight: 1.45 }}>
|
||||||
|
<div>需求 · 诊断 · 规划 · 对接 · 落地</div>
|
||||||
|
<div>温州市人工智能局</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<div style={{ width: 1, height: 44, background: 'linear-gradient(to bottom, transparent, rgba(0,212,255,0.2), transparent)', flexShrink: 0, marginRight: 24, position: 'relative', zIndex: 1 }} />
|
||||||
|
|
||||||
|
{/* 统计指标 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'center', position: 'relative', zIndex: 1, marginTop: 24 }}>
|
||||||
|
{[
|
||||||
|
{ label: '入库需求', value: String(totalDemands), color: '#a8d8ff' },
|
||||||
|
{ label: '对接中', value: String(matchingCount), color: statusConfig.matching.color },
|
||||||
|
{ label: '试点落地', value: String(pilotCount), color: statusConfig.pilot.color },
|
||||||
|
{ label: '待对接', value: String(newCount), color: statusConfig.new.color },
|
||||||
|
].map((item, idx) => (
|
||||||
|
<div key={item.label} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center', padding: '6px 18px', borderRadius: 10,
|
||||||
|
background: 'rgba(0,212,255,0.05)', border: '1px solid rgba(0,212,255,0.1)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: item.color, fontSize: 22, fontWeight: 700, lineHeight: 1, textShadow: `0 0 10px ${item.color}80` }}>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, marginTop: 2, letterSpacing: '0.04em' }}>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{idx < 3 && <div style={{ width: 1, height: 28, background: 'rgba(0,212,255,0.1)', margin: '0 3px' }} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右分隔线 */}
|
||||||
|
<div style={{ width: 1, height: 44, background: 'linear-gradient(to bottom, transparent, rgba(0,212,255,0.2), transparent)', flexShrink: 0, marginLeft: 24, position: 'relative', zIndex: 1 }} />
|
||||||
|
|
||||||
|
{/* 时间 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flex: 1, justifyContent: 'flex-end', position: 'relative', zIndex: 1 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px', borderRadius: 8, cursor: 'pointer',
|
||||||
|
border: '1px solid rgba(0,212,255,0.55)',
|
||||||
|
background: 'rgba(0,212,255,0.08)',
|
||||||
|
color: '#00d4ff', fontSize: 12, fontWeight: 600,
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0,212,255,0.18)'; e.currentTarget.style.borderColor = '#00d4ff'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(0,212,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(0,212,255,0.55)'; }}
|
||||||
|
>
|
||||||
|
← 返回平台
|
||||||
|
</button>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ color: '#00d4ff', fontSize: 20, fontWeight: 700, fontVariantNumeric: 'tabular-nums', lineHeight: 1, textShadow: '0 0 16px rgba(0,212,255,0.7)', letterSpacing: '0.05em' }}>
|
||||||
|
{timeStr}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 2, letterSpacing: '0.04em' }}>
|
||||||
|
{dateStr}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(0,212,255,0.55)', fontSize: 11, marginTop: 4, letterSpacing: '0.06em' }}>
|
||||||
|
Powered by EIOS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { COUPONS, couponTypeConfig } from '../data/coupons';
|
||||||
|
import TechIcon, { type TechIconName } from './TechIcons';
|
||||||
|
import type { AIDemand } from '../data/demands';
|
||||||
|
import type { Translator } from '../data/translators';
|
||||||
|
import { generateDesignDoingReply, searchDesignDoing } from '../data/designdoing';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: number;
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
thinking: string;
|
||||||
|
content: string;
|
||||||
|
thinkingDone: boolean;
|
||||||
|
done: boolean;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = 'chat' | 'maturity' | 'roadmap' | 'coupons';
|
||||||
|
|
||||||
|
const getTimeStr = () => {
|
||||||
|
const now = new Date();
|
||||||
|
return `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUICK_CMDS = ['AI成熟度问诊', 'Design Doing方法论', '推荐落地方案', '匹配AI翻译官', '查询优惠券', '政策解读'];
|
||||||
|
|
||||||
|
const MATURITY_STEPS = [
|
||||||
|
{ q: '企业是否已部署MES/SCADA/IoT等数据采集系统?', sub: '设备数字化率、联网率如何?数据是否可自动采集?', key: 'data_system', weight: 10, category: '自动化和数字化基础' },
|
||||||
|
{ q: '核心生产/经营数据积累情况如何?', sub: '是否有结构化存储?积累了多长时间?数据质量(完整性/一致性)如何?', key: 'data_quality', weight: 10, category: '数据与知识积累' },
|
||||||
|
{ q: '是否已部署ERP/MES/WMS/PLM等核心业务系统?', sub: '系统间是否实现数据互通?覆盖哪些核心工序?', key: 'it_system', weight: 10, category: '自动化和数字化基础' },
|
||||||
|
{ q: '是否有专职IT/数字化团队?', sub: '团队规模和AI基础通识水平如何?是否有外部数字化服务团队支持?', key: 'it_team', weight: 10, category: '团队与人才基础' },
|
||||||
|
{ q: '核心工艺知识是否有系统化沉淀?', sub: 'SOP/故障案例库/工艺参数优化经验,是纸质文档还是系统内置?是否建有企业知识库?', key: 'knowledge', weight: 10, category: '数据与知识积累' },
|
||||||
|
{ q: '是否已尝试过AI或机器学习项目?', sub: '引入了哪些工具?应用场景、使用频率及效果如何?未引入的原因是什么?', key: 'ai_exp', weight: 10, category: 'AI应用现状' },
|
||||||
|
{ q: '管理层是否有明确的AI转型目标和预算?', sub: '一把手是否亲自参与?是否制定1-2年短期或3-5年中长期AI规划?', key: 'leadership', weight: 10, category: 'AI提升意愿' },
|
||||||
|
{ q: '是否已识别出3个以上清晰的AI应用场景?', sub: '这些场景是单企业需求还是行业共性需求?核心价值和可行性如何评估?', key: 'scenario', weight: 10, category: 'AI应用现状' },
|
||||||
|
{ q: '推进AI应用的主要顾虑是什么?', sub: '技术不成熟/数据安全/投入回报不确定/员工抵触?愿意投入的资源力度?', key: 'concern', weight: 10, category: 'AI提升意愿' },
|
||||||
|
{ q: '是否已有明确的AI算力和部署规划?', sub: '自建/租赁/云边端混合?是否有国产化适配要求?预算规划如何?', key: 'infra', weight: 10, category: 'AI应用现状' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface BottomAIBarProps {
|
||||||
|
selectedDemand: AIDemand | null;
|
||||||
|
selectedTranslator: Translator | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BottomAIBar({ selectedDemand, selectedTranslator }: BottomAIBarProps) {
|
||||||
|
const [tab, setTab] = useState<TabType>('chat');
|
||||||
|
const [messages, setMessages] = useState<Message[]>([{
|
||||||
|
id: 0, role: 'ai',
|
||||||
|
thinking: '',
|
||||||
|
content: '您好!我是温州市“AI+制造”智创空间助手。\n\n我可以帮温州制造企业:\n• AI成熟度问诊评级(Design Doing方法论·10步5维)— 评估数字化基础、数据积累、团队人才、AI应用现状、AI提升意愿\n• 解析真实企业AI需求与场景痛点 — 由AI翻译官将业务语言转译为可交付任务\n• 推荐AI落地规划路线 — 生成从现状诊断到规模推广的分阶段实施路径\n• 匹配温州市AI+制造优惠券与政策支持 — 自动匹配算力券、语料券、咨询券、工具券、应用券\n\n试试快捷指令"Design Doing方法论",或直接提问任意步骤(如"数据采集怎么评估""工艺知识""算力规划"),我会为您详细解答!',
|
||||||
|
thinkingDone: true, done: true, time: getTimeStr(),
|
||||||
|
}]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [maturityAnswers, setMaturityAnswers] = useState<Record<string, boolean | string | null>>({});
|
||||||
|
const [maturityStep, setMaturityStep] = useState(0);
|
||||||
|
const [maturityDone, setMaturityDone] = useState(false);
|
||||||
|
const [claimedCoupons, setClaimedCoupons] = useState<Set<string>>(new Set());
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}, [messages, isTyping]);
|
||||||
|
|
||||||
|
const streamText = useCallback((msgId: number, field: 'thinking' | 'content', fullText: string, charDelay: number, onDone?: () => void) => {
|
||||||
|
let idx = 0;
|
||||||
|
const tick = () => {
|
||||||
|
idx += Math.floor(Math.random() * 2) + 1;
|
||||||
|
const slice = fullText.slice(0, Math.min(idx, fullText.length));
|
||||||
|
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, [field]: slice } : m));
|
||||||
|
if (idx < fullText.length) {
|
||||||
|
streamTimerRef.current = setTimeout(tick, charDelay);
|
||||||
|
} else {
|
||||||
|
if (field === 'thinking') setMessages(prev => prev.map(m => m.id === msgId ? { ...m, thinkingDone: true } : m));
|
||||||
|
else setMessages(prev => prev.map(m => m.id === msgId ? { ...m, done: true } : m));
|
||||||
|
onDone?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
streamTimerRef.current = setTimeout(tick, charDelay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildAIReply = useCallback((text: string): { thinking: string; content: string } => {
|
||||||
|
// Design Doing 方法论知识库检索(优先匹配)
|
||||||
|
const ddSearch = searchDesignDoing(text);
|
||||||
|
if (ddSearch.isOverviewQuery || ddSearch.isMethodologyQuery || ddSearch.matchedSteps.length > 0) {
|
||||||
|
return generateDesignDoingReply(
|
||||||
|
text,
|
||||||
|
selectedDemand ? {
|
||||||
|
company: selectedDemand.company,
|
||||||
|
industry: selectedDemand.industry,
|
||||||
|
maturityLevel: selectedDemand.maturityLevel,
|
||||||
|
maturityScore: selectedDemand.maturityScore,
|
||||||
|
dataReadiness: selectedDemand.dataReadiness,
|
||||||
|
} : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (text.includes('成熟度') || text.includes('问诊')) {
|
||||||
|
return {
|
||||||
|
thinking: `启动温州市AI成熟度评估模型…\n分析企业所在温州重点产业:${selectedDemand?.industry || '温州制造业'}…\n调取温州市“人工智能+制造”典型场景参考指引…\n结合问诊结果生成评级报告…`,
|
||||||
|
content: selectedDemand
|
||||||
|
? `${selectedDemand.company} AI成熟度评估结果\n\n评级:${selectedDemand.maturityLevel}级(评分 ${selectedDemand.maturityScore}/100)\n\n评估维度(基于浙江版制造型企业AI场景调研提纲10步评估):\n\n一、自动化和数字化基础\n• 数据采集系统:${selectedDemand.dataReadiness >= 70 ? '已部署MES/SCADA/IoT,设备数字化率较高' : '数据采集体系尚在建设中'}\n• 核心业务系统:${selectedDemand.dataReadiness >= 60 ? 'ERP/MES/WMS已部分覆盖,系统集成度待提升' : '核心系统部署率较低,建议优先补齐'}\n\n二、数据与知识积累\n• 数据资产积累:${selectedDemand.dataReadiness}% 就绪度,${selectedDemand.dataReadiness >= 70 ? '数据质量较好,已具备AI建模基础' : '数据质量与结构化程度需进一步提升'}\n• 工艺知识沉淀:${selectedDemand.technicalDesc ? '已有部分结构化知识,建议建设企业专属知识库' : '以纸质文档和老员工经验为主,亟需系统化沉淀'}\n\n三、团队与人才基础\n• IT/数字化团队:${selectedDemand.department}主导,建议配置专职AI人员\n• AI通识水平:建议组织温州AI+制造专项培训\n\n四、AI应用现状\n• AI项目经验:${selectedDemand.maturityScore >= 60 ? '已有探索性项目,需建立系统化AI能力底座' : '尚处起步阶段,建议从单点场景切入'}\n• 场景识别:已明确“${selectedDemand.scenario}”核心场景\n\n五、AI提升意愿\n• 领导力与预算:预算区间 ${selectedDemand.budget},${selectedDemand.maturityScore >= 50 ? '管理层AI转型目标较为明确' : '建议加强一把手重视与预算保障'}\n• 落地顾虑:建议引入AI翻译官进行需求精化,明确技术边界与验收KPI\n\n下一步:\n• 把该企业需求拖入中间“AI成熟度问诊”模块查看五维详细诊断\n• 或拖入“AI落地规划”模块查看分阶段路线图`
|
||||||
|
: `AI成熟度10步问诊\n\n请先从左侧选择或拖拽一家企业需求到中间“AI成熟度问诊”模块。\n\n问诊维度(基于浙江省制造型企业AI场景调研提纲):\n\n一、自动化和数字化基础\n• 步骤1:数据采集系统(MES/SCADA/IoT)部署情况\n• 步骤3:核心业务系统(ERP/MES/WMS/PLM)覆盖与集成\n\n二、数据与知识积累\n• 步骤2:生产/经营数据积累与质量\n• 步骤5:工艺知识沉淀形式与知识库建设\n\n三、团队与人才基础\n• 步骤4:专职IT/数字化团队与AI通识水平\n\n四、AI应用现状\n• 步骤6:已引入AI工具与项目经验\n• 步骤8:AI应用场景识别清晰度\n• 步骤10:AI算力与部署规划\n\n五、AI提升意愿\n• 步骤7:管理层目标与预算\n• 步骤9:推进顾虑与资源投入意愿`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (text.includes('方案') || text.includes('规划') || text.includes('落地')) {
|
||||||
|
return {
|
||||||
|
thinking: `分析温州企业需求场景:${selectedDemand?.scenario || '温州市AI+制造应用'}…\n匹配温州市“人工智能+制造”典型场景参考指引…\n评估数据就绪度与实施风险…\n生成分阶段落地路线图…`,
|
||||||
|
content: selectedDemand
|
||||||
|
? `温州市AI+制造落地规划建议 · ${selectedDemand.company}\n\n目标场景:${selectedDemand.scenario}\n所属产业:${selectedDemand.industry}|区域:${selectedDemand.region}\n主导部门:${selectedDemand.department}|期望周期:${selectedDemand.timeline}\n\n阶段一(1-3个月)· 数据摸底与现状诊断\n• 盘点现有数据资产,评估质量与完整性\n• 对照温州市典型场景参考指引定义KPI\n• 选定首批试点区域/产线\n• 引入AI翻译官完成需求精化与场景可行性分析\n\n阶段二(3-6个月)· 试点验证与效果追踪\n• 申请算力券+咨询券启动开发\n• 在试点范围验证AI效果,建立ROI跟踪机制\n• 方案迭代优化,确保可量化验收\n\n阶段三(6-12个月)· 规模推广与持续运营\n• 基于试点结果优化模型与部署架构\n• 逐步扩展至全部应用范围\n• 建立AI运维与持续优化机制\n• 复制成功经验至同产业其他企业\n\n预算参考:${selectedDemand.budget}(建议申报温州市AI+制造专项支持)\n\n提示:把该企业需求拖入中间“AI落地规划”模块查看完整路线图与阶段明细`
|
||||||
|
: `请先选择或拖拽企业需求到中间模块,我将为您生成专属AI落地规划方案。\n\n您也可以尝试快捷指令:\n• “AI成熟度问诊” — 先了解企业AI准备度\n• “匹配AI翻译官” — 找到合适的专家进行需求精化\n• “查询优惠券” — 了解可用的政策支持`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (text.includes('翻译官') || text.includes('匹配')) {
|
||||||
|
return {
|
||||||
|
thinking: `分析需求场景关键词…\n检索AI翻译官专长标签库…\n计算需求-翻译官匹配度…\n输出推荐排序…`,
|
||||||
|
content: selectedDemand
|
||||||
|
? `AI翻译官智能推荐 · ${selectedDemand.scenario}\n\n当前需求来自 ${selectedDemand.company}(${selectedDemand.region},${selectedDemand.industry}),场景属于温州市“人工智能+制造”重点方向。\n\n一、业务痛点转译\n• 一句话描述痛点:${selectedDemand.painPoint}\n• 核心期望价值:通过AI技术实现该环节效率提升、人工依赖降低、质量一致性提高\n• AI可能扮演的角色:作为“智能助手”与“数据分析专家”,对业务数据进行统一分析,沉淀经验与知识,提供优化建议与决策支持\n\n二、数据与技术基础\n• 数据就绪度:${selectedDemand.dataReadiness}%,${selectedDemand.dataReadiness >= 70 ? '已具备一定结构化数据基础,可支撑AI建模起步' : '建议先完成数据资产盘点与质量评估'}\n• 现有系统:${selectedDemand.technicalDesc ? '已有部分业务系统覆盖,系统集成度待进一步提升' : '需确认现有ERP/MES/WMS等系统部署情况与数据互通能力'}\n• 算力规划:建议评估本地部署与云端训练混合方案\n\n三、翻译官推荐\n建议优先匹配三类专家:\n1. 温州本地制造业甲方专家 — 熟悉${selectedDemand.industry}真实产线与业务语言,最懂甲方痛点\n2. 温州高校/研究院专家 — 适合算法验证、样本构建与技术路线评审\n3. 工业AI服务商专家 — 适合系统集成、交付实施与ROI测算\n\n四、验收标准与对接流程\n• MVP验收:在试点数据集上核心指标达标;完成2-3家合作企业现场试用\n• 最终验收:真实场景连续稳定运行1个月+,核心KPI提升≥30%\n• 对接流程:①企业提交场景痛点与数据样本 ②AI翻译官输出需求说明与技术边界 ③进入落地规划与服务商对接\n\n点击右侧AI翻译官卡片发起对接申请`
|
||||||
|
: `请先选择或拖拽企业需求,我将为您推荐最匹配的AI翻译官。\n\nAI翻译官的职责:\n• 解析制造企业AI需求和场景痛点,量化业务损失与“理想状态”目标\n• 评估数据可用性、现有IT设施与算力资源\n• 用业务语言描述问题和解法,明确技术边界、输入数据和验收KPI\n• 定义MVP验收标准与最终项目验收标准\n\n来源:温州市人工智能局专家库、温州大学智能制造研究团队、乐清智能电气产业顾问组等`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (text.includes('优惠券') || text.includes('券')) {
|
||||||
|
return {
|
||||||
|
thinking: `查询温州市人工智能局AI惠企政策库…\n匹配企业区域、产业类型与需求场景…\n过滤可用优惠券…\n计算可获得支持方向…`,
|
||||||
|
content: `可领取AI优惠券(${COUPONS.length}种)\n\n立即可用:\n• 算力券 — 支持模型训练、边缘推理和云端部署,覆盖工业视觉、预测维护等场景\n• AI翻译官咨询券 — 温州企业首次需求解析与成熟度诊断,8课时1对1服务\n• 工具券 — 支持工业大模型、知识库、视觉检测等试用,3个月免费API调用\n\n申请制:\n• 语料券 — 工业图像标注、工艺资料结构化、售后知识库整理\n• 应用券 — 需完成AI落地规划认定后进入试点支持,专项补贴最高¥10000\n\n发放机构:温州市人工智能局\n适用对象:温州市AI成熟度L1-L3制造企业\n\n提示:把企业需求拖入中间“优惠券领取”模块可查看全部详情并匹配申领`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (text.includes('政策') || text.includes('补贴')) {
|
||||||
|
return {
|
||||||
|
thinking: `检索温州市“AI+制造”扶持政策库…\n匹配区县、产业链和典型场景…\n汇总可申请支持路径…`,
|
||||||
|
content: `温州市“AI+制造”政策解读\n\n支持方向:\n• 围绕智能电气、鞋业服装、汽车零部件、工业泵阀、新能源、智能装备等温州重点产业开展AI试点\n• 支持AI质量检测、AI计划排产、设备故障诊断、产品AI设计、智能经营决策等典型场景\n• 支持温州企业建设行业知识库、场景智能体、数据中台和AI应用示范线\n\n申报路径:\n1. 在本平台完成AI成熟度问诊评级\n2. 由AI翻译官输出业务语言版需求说明和技术路线建议\n3. 提交AI落地规划与试点KPI\n4. 通过温州市人工智能局认定后领取对应优惠券或试点支持\n\n政策来源:\n• 2026年第一批人工智能赋能新型工业化需求清单\n• 温州市“人工智能+制造”典型场景参考指引(试行)\n• 温州市AI+制造专项补贴与优惠券管理办法`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 通用回复
|
||||||
|
const name = selectedTranslator ? `【@${selectedTranslator.name}】` : '';
|
||||||
|
return {
|
||||||
|
thinking: `${name ? `调用${name}专家知识库…\n` : ''}分析问题关键信息…\n结合制造业AI落地经验…\n生成个性化回答…`,
|
||||||
|
content: `${name ? `${name} ` : ''}收到您的问题:"${text}"\n\n基于温州市“AI+制造”智创空间分析:\n\n${selectedDemand ? `当前聚焦企业:${selectedDemand.company}\n区域:${selectedDemand.region}\n产业:${selectedDemand.industry}\n场景:${selectedDemand.scenario}\n成熟度:${selectedDemand.maturityLevel}(评分 ${selectedDemand.maturityScore}/100)\n数据就绪度:${selectedDemand.dataReadiness}%\n预算区间:${selectedDemand.budget}\n期望周期:${selectedDemand.timeline}\n\n` : ''}建议您使用快捷指令进行精准查询:\n• 输入“AI成熟度问诊” — 了解温州企业AI准备度\n• 输入“推荐落地方案” — 获取定制化路线图\n• 输入“匹配AI翻译官” — 找到最合适的专家\n• 输入“查询优惠券” — 了解可领取的政策支持\n• 输入“政策解读” — 查看温州市AI+制造扶持政策\n\n或者直接把左侧企业AI需求拖入中间 Agentic Space 的四个模块,即可生成对应内容。`,
|
||||||
|
};
|
||||||
|
}, [selectedDemand, selectedTranslator]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback((text: string) => {
|
||||||
|
if (!text.trim() || isTyping) return;
|
||||||
|
setTab('chat');
|
||||||
|
setMessages(prev => [...prev, { id: Date.now(), role: 'user', thinking: '', content: text, thinkingDone: true, done: true, time: getTimeStr() }]);
|
||||||
|
setInput('');
|
||||||
|
setIsTyping(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const { thinking, content } = buildAIReply(text);
|
||||||
|
const aiMsgId = Date.now() + 1;
|
||||||
|
setMessages(prev => [...prev, { id: aiMsgId, role: 'ai', thinking: '', content: '', thinkingDone: false, done: false, time: getTimeStr() }]);
|
||||||
|
streamText(aiMsgId, 'thinking', thinking, 55, () => {
|
||||||
|
setTimeout(() => { streamText(aiMsgId, 'content', content, 40, () => setIsTyping(false)); }, 600);
|
||||||
|
});
|
||||||
|
}, 450);
|
||||||
|
}, [isTyping, buildAIReply, streamText]);
|
||||||
|
|
||||||
|
const maturityScore = Object.values(maturityAnswers).filter(v => v === true).length;
|
||||||
|
const maturityLevel = maturityScore <= 2 ? 'L1' : maturityScore <= 4 ? 'L2' : maturityScore <= 6 ? 'L3' : maturityScore <= 8 ? 'L4' : 'L5';
|
||||||
|
const currentStep = MATURITY_STEPS[maturityStep];
|
||||||
|
const currentCategory = currentStep?.category || '';
|
||||||
|
|
||||||
|
const chatHeight = expanded ? 380 : 90;
|
||||||
|
const ARC_H = 60;
|
||||||
|
|
||||||
|
const TABS: { key: TabType; label: string; icon: TechIconName }[] = [
|
||||||
|
{ key: 'chat', label: '智能问答', icon: 'chat' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-up" style={{ flexShrink: 0, paddingLeft: '295px', paddingRight: '310px', paddingBottom: 0, position: 'relative', overflow: 'visible' }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{/* 弧形顶部 */}
|
||||||
|
<svg viewBox="0 0 1000 110" preserveAspectRatio="none" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: ARC_H, pointerEvents: 'none', zIndex: 1, display: 'block', overflow: 'visible' }}>
|
||||||
|
<path d="M 0,110 C 150,-40 850,-40 1000,110 L 1000,110 L 0,110 Z" fill="rgba(0,15,50,0.88)" />
|
||||||
|
<path d="M 0,110 C 150,-40 850,-40 1000,110" fill="none" stroke="rgba(0,212,255,0.5)" strokeWidth="1.2" />
|
||||||
|
<circle cx="0" cy="110" r="5" fill="rgba(0,212,255,0.8)" />
|
||||||
|
<circle cx="1000" cy="110" r="5" fill="rgba(0,212,255,0.8)" />
|
||||||
|
<circle cx="500" cy="2" r="3" fill="rgba(0,212,255,0.5)" />
|
||||||
|
<circle cx="200" cy="35" r="2" fill="rgba(0,212,255,0.3)" />
|
||||||
|
<circle cx="800" cy="35" r="2" fill="rgba(0,212,255,0.3)" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{ position: 'absolute', top: ARC_H - 1, left: 0, right: 0, bottom: 0, zIndex: 0, background: 'linear-gradient(to bottom, rgba(0,15,50,0.88), rgba(0,8,28,0.95))', backdropFilter: 'blur(18px)', WebkitBackdropFilter: 'blur(18px)' }} />
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', zIndex: 2, paddingTop: ARC_H }}>
|
||||||
|
|
||||||
|
{/* Tab 切换栏 */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, padding: '6px 20px 0', borderBottom: '1px solid rgba(0,212,255,0.1)' }}>
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button key={t.key} onClick={() => setTab(t.key)} style={{
|
||||||
|
padding: '6px 16px', borderRadius: '8px 8px 0 0', fontSize: 16, fontWeight: 700, cursor: 'pointer',
|
||||||
|
background: tab === t.key ? 'rgba(0,212,255,0.1)' : 'transparent',
|
||||||
|
color: tab === t.key ? '#00d4ff' : 'rgba(255,255,255,0.5)',
|
||||||
|
borderBottom: tab === t.key ? '2px solid #00d4ff' : '2px solid transparent',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<TechIcon name={t.icon} color={tab === t.key ? '#00d4ff' : 'rgba(255,255,255,0.5)'} size={18} />
|
||||||
|
{t.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 6 }}>
|
||||||
|
{selectedDemand && (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: 'rgba(0,212,255,0.08)', border: '1px solid rgba(0,212,255,0.2)', color: '#00d4ff', fontSize: 12 }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}><TechIcon name="pin" color="#00d4ff" size={13} />{selectedDemand.company}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedTranslator && (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: 'rgba(123,97,255,0.08)', border: '1px solid rgba(123,97,255,0.2)', color: '#7b61ff', fontSize: 12 }}>
|
||||||
|
@{selectedTranslator.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setExpanded(v => !v)} style={{ padding: '3px 10px', borderRadius: 7, border: '1px solid rgba(255,255,255,0.08)', background: 'transparent', color: 'rgba(255,255,255,0.4)', fontSize: 12, cursor: 'pointer' }}>
|
||||||
|
{expanded ? <><TechIcon name="collapse" color="rgba(255,255,255,0.45)" size={13} /> 收起</> : <><TechIcon name="expand" color="rgba(255,255,255,0.45)" size={13} /> 展开</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── TAB: 智能问答 ─── */}
|
||||||
|
{tab === 'chat' && (
|
||||||
|
<>
|
||||||
|
<div ref={scrollRef} className="space-scroll" style={{ height: chatHeight, overflowY: 'auto', padding: '8px 24px 4px', transition: 'height 0.25s ease' }}>
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} style={{ display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start', marginBottom: 6 }}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<span style={{ width: 28, height: 28, borderRadius: '50%', background: 'linear-gradient(135deg,rgba(0,30,80,0.9),rgba(0,60,120,0.85))', border: '1px solid rgba(0,212,255,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginRight: 7, marginTop: 2 }}><TechIcon name="bot" color="#00d4ff" size={16} /></span>
|
||||||
|
)}
|
||||||
|
<div style={{ maxWidth: '82%' }}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<>
|
||||||
|
{msg.thinking && (
|
||||||
|
<div style={{ padding: '4px 10px', borderRadius: '9px 9px 9px 2px', background: 'rgba(0,40,80,0.35)', border: '1px solid rgba(0,212,255,0.07)', marginBottom: msg.thinkingDone && msg.content ? 5 : 0 }}>
|
||||||
|
<span style={{ fontStyle: 'italic', color: 'rgba(255,255,255,0.6)', fontSize: 14, lineHeight: 1.65, whiteSpace: 'pre-line', display: 'block' }}>
|
||||||
|
{!msg.thinkingDone && <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.5)', marginRight: 5, verticalAlign: 'middle', animation: 'space-pulse 0.8s ease-in-out infinite' }} />}
|
||||||
|
{msg.thinking}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg.thinkingDone && msg.content && (
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '2px 12px 12px 12px', background: 'rgba(0,20,60,0.55)', border: '1px solid rgba(0,212,255,0.12)', color: 'rgba(255,255,255,0.92)', fontSize: 15, lineHeight: 1.65, whiteSpace: 'pre-line' }}>
|
||||||
|
{msg.content}
|
||||||
|
{!msg.done && <span style={{ display: 'inline-block', width: 2, height: '1em', background: '#00d4ff', marginLeft: 2, verticalAlign: 'text-bottom', animation: 'space-pulse 0.6s ease-in-out infinite' }} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '12px 12px 2px 12px', background: 'rgba(0,122,255,0.22)', border: '1px solid rgba(0,122,255,0.3)', color: 'rgba(255,255,255,0.95)', fontSize: 15, lineHeight: 1.65, whiteSpace: 'pre-line' }}>{msg.content}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.3)', fontSize: 12, marginTop: 2, textAlign: msg.role === 'user' ? 'right' : 'left' }}>{msg.time}</div>
|
||||||
|
</div>
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<span style={{ width: 26, height: 26, borderRadius: '50%', background: 'rgba(0,122,255,0.2)', border: '1px solid rgba(0,122,255,0.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginLeft: 7, marginTop: 2 }}><TechIcon name="user" color="#5aabff" size={14} /></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isTyping && messages[messages.length - 1]?.role !== 'ai' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||||
|
<span style={{ width: 28, height: 28, borderRadius: '50%', background: 'linear-gradient(135deg,rgba(0,30,80,0.9),rgba(0,60,120,0.85))', border: '1px solid rgba(0,212,255,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><TechIcon name="bot" color="#00d4ff" size={16} /></span>
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '12px 12px 12px 2px', background: 'rgba(0,20,60,0.55)', border: '1px solid rgba(0,212,255,0.12)', display: 'flex', gap: 5, alignItems: 'center' }}>
|
||||||
|
{[0,1,2].map(i => <span key={i} style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#00d4ff', animation: `space-pulse 1s ${i * 0.2}s ease-in-out infinite` }} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<div style={{ padding: '4px 24px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 7, flexWrap: 'wrap' }}>
|
||||||
|
{QUICK_CMDS.map(cmd => (
|
||||||
|
<button key={cmd} onClick={() => sendMessage(cmd)} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 18, border: '1px solid rgba(0,212,255,0.2)', background: 'rgba(0,212,255,0.06)', color: 'rgba(255,255,255,0.8)', fontSize: 12, cursor: 'pointer', transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background='rgba(0,212,255,0.14)'; e.currentTarget.style.borderColor='rgba(0,212,255,0.4)'; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background='rgba(0,212,255,0.06)'; e.currentTarget.style.borderColor='rgba(0,212,255,0.2)'; }}
|
||||||
|
>{cmd}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => { if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(input); } }}
|
||||||
|
placeholder={selectedDemand ? `向AI助手提问关于 ${selectedDemand.company} 的AI需求…` : '输入问题,如:帮我分析这家企业的AI落地优先级…'}
|
||||||
|
style={{ flex: 1, padding: '9px 16px', borderRadius: 22, background: 'rgba(0,30,70,0.6)', border: '1px solid rgba(0,212,255,0.2)', color: 'rgba(255,255,255,0.92)', fontSize: 13, outline: 'none', transition: 'border-color 0.15s ease' }}
|
||||||
|
onFocus={e => (e.target.style.borderColor='rgba(0,212,255,0.5)')}
|
||||||
|
onBlur={e => (e.target.style.borderColor='rgba(0,212,255,0.2)')}
|
||||||
|
/>
|
||||||
|
<button onClick={() => sendMessage(input)} disabled={!input.trim() || isTyping} style={{
|
||||||
|
width: 40, height: 40, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: input.trim() && !isTyping ? 'linear-gradient(135deg, #0066cc, #00aaff)' : 'rgba(0,50,100,0.3)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.3)', color: input.trim() && !isTyping ? '#fff' : 'rgba(255,255,255,0.3)',
|
||||||
|
fontSize: 17, cursor: input.trim() && !isTyping ? 'pointer' : 'not-allowed',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: input.trim() && !isTyping ? '0 0 12px rgba(0,170,255,0.4)' : 'none',
|
||||||
|
}}><TechIcon name="deploy" color={input.trim() && !isTyping ? '#fff' : 'rgba(255,255,255,0.3)'} size={17} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── TAB: 成熟度问诊 ─── */}
|
||||||
|
{tab === 'maturity' && (
|
||||||
|
<div style={{ padding: '12px 24px 16px', minHeight: 160 }}>
|
||||||
|
{!maturityDone ? (
|
||||||
|
<>
|
||||||
|
<div style={{ color: '#a8d8ff', fontSize: 14, fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}><TechIcon name="maturity" color="#00d4ff" size={18} />AI成熟度10步评估 · {currentCategory}</span>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>第 {maturityStep + 1} / {MATURITY_STEPS.length} 步</span>
|
||||||
|
</div>
|
||||||
|
{maturityStep < MATURITY_STEPS.length ? (
|
||||||
|
<div style={{ padding: '14px 18px', borderRadius: 12, background: 'rgba(0,212,255,0.06)', border: '1px solid rgba(0,212,255,0.2)' }}>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.92)', fontSize: 15, margin: '0 0 6px', lineHeight: 1.6, fontWeight: 600 }}>
|
||||||
|
Q{maturityStep + 1}. {MATURITY_STEPS[maturityStep].q}
|
||||||
|
</p>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, margin: '0 0 14px', lineHeight: 1.55 }}>
|
||||||
|
{MATURITY_STEPS[maturityStep].sub}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
{[
|
||||||
|
{ label: '已具备/较好', icon: 'check' as TechIconName, value: true, color: '#00e599' },
|
||||||
|
{ label: '尚未具备/较弱', icon: 'close' as TechIconName, value: false, color: '#ff6b6b' },
|
||||||
|
{ label: '部分具备', icon: 'target' as TechIconName, value: 'partial' as string | boolean, color: '#ffb340' },
|
||||||
|
].map(opt => (
|
||||||
|
<button key={String(opt.value)} onClick={() => {
|
||||||
|
setMaturityAnswers(prev => ({ ...prev, [MATURITY_STEPS[maturityStep].key]: opt.value === 'partial' ? 'partial' : opt.value }));
|
||||||
|
if (maturityStep + 1 >= MATURITY_STEPS.length) setMaturityDone(true);
|
||||||
|
else setMaturityStep(s => s + 1);
|
||||||
|
}} style={{
|
||||||
|
flex: 1, padding: '10px 0', borderRadius: 8, fontSize: 14, fontWeight: 700, cursor: 'pointer',
|
||||||
|
background: `${opt.color}18`, border: `1px solid ${opt.color}40`, color: opt.color, transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background=`${opt.color}28`; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background=`${opt.color}18`; }}
|
||||||
|
><span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 7 }}><TechIcon name={opt.icon} color={opt.color} size={15} />{opt.label}</span></button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* 进度 */}
|
||||||
|
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(0,212,255,0.1)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${(maturityStep / MATURITY_STEPS.length) * 100}%`, height: '100%', background: 'linear-gradient(to right, #00d4ff, #00e599)', borderRadius: 2, transition: 'width 0.3s ease' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>{maturityStep}/{MATURITY_STEPS.length}</span>
|
||||||
|
{maturityStep > 0 && <button onClick={() => { setMaturityStep(s => s - 1); }} style={{ padding: '3px 8px', borderRadius: 6, background: 'transparent', border: '1px solid rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.5)', fontSize: 11, cursor: 'pointer' }}>上一步</button>}
|
||||||
|
<button onClick={() => { setMaturityStep(0); setMaturityAnswers({}); setMaturityDone(false); }} style={{ padding: '3px 8px', borderRadius: 6, background: 'transparent', border: '1px solid rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.5)', fontSize: 11, cursor: 'pointer' }}>重置</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ color: '#00e599', fontSize: 16, fontWeight: 700, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 7 }}><TechIcon name="check" color="#00e599" size={18} />10步评估完成!</div>
|
||||||
|
<div style={{ padding: '12px 14px', borderRadius: 10, background: 'rgba(0,229,153,0.06)', border: '1px solid rgba(0,229,153,0.2)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 28, fontWeight: 700, color: '#00e599' }}>{maturityLevel}</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 14, fontWeight: 600 }}>AI成熟度等级</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>综合得分 {maturityScore * 10}/100</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 分类小结 */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 10 }}>
|
||||||
|
{['自动化和数字化基础', '数据与知识积累', '团队与人才基础', 'AI应用现状', 'AI提升意愿'].map(cat => {
|
||||||
|
const catKeys = MATURITY_STEPS.filter(s => s.category === cat).map(s => s.key);
|
||||||
|
const catYes = catKeys.filter(k => maturityAnswers[k] === true).length;
|
||||||
|
const catTotal = catKeys.length;
|
||||||
|
const catPct = Math.round((catYes / catTotal) * 100);
|
||||||
|
return (
|
||||||
|
<div key={cat} style={{ padding: '6px 10px', borderRadius: 8, background: 'rgba(0,212,255,0.06)', border: '1px solid rgba(0,212,255,0.12)' }}>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 11, marginBottom: 4 }}>{cat}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.08)' }}>
|
||||||
|
<div style={{ width: `${catPct}%`, height: '100%', borderRadius: 2, background: catPct >= 70 ? '#00e599' : catPct >= 40 ? '#ffb340' : '#ff6b6b' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: catPct >= 70 ? '#00e599' : catPct >= 40 ? '#ffb340' : '#ff6b6b', fontSize: 11, fontWeight: 700 }}>{catPct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.8)', fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||||
|
{maturityLevel === 'L1' && '处于AI启蒙期,建议从基础数字化建设入手:先建立数据采集能力、部署核心业务系统,再逐步积累工艺知识。'}
|
||||||
|
{maturityLevel === 'L2' && '具备探索基础,建议聚焦1-2个清晰场景进行AI小规模试点验证,同时补强数据治理和团队AI技能。'}
|
||||||
|
{maturityLevel === 'L3' && '已有一定AI基础,适合推进场景规模化复制和AI能力体系建设,重点加强数据互通与知识沉淀。'}
|
||||||
|
{maturityLevel === 'L4' && 'AI能力较成熟,可考虑构建自主AI开发能力和智能决策支持系统,探索算力基础设施优化。'}
|
||||||
|
{maturityLevel === 'L5' && '行业AI领先水平,建议向AI驱动业务创新方向演进,探索AI商业化与行业赋能输出。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setMaturityStep(0); setMaturityAnswers({}); setMaturityDone(false); }} style={{ marginTop: 8, padding: '6px 16px', borderRadius: 8, background: 'rgba(0,212,255,0.08)', border: '1px solid rgba(0,212,255,0.25)', color: '#00d4ff', fontSize: 12, cursor: 'pointer' }}>重新评估</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── TAB: AI落地规划 ─── */}
|
||||||
|
{tab === 'roadmap' && (
|
||||||
|
<div style={{ padding: '12px 24px 16px' }}>
|
||||||
|
<div style={{ color: '#a8d8ff', fontSize: 13, fontWeight: 600, marginBottom: 10 }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}><TechIcon name="roadmap" color="#00d4ff" size={17} />{selectedDemand ? `${selectedDemand.company} · AI落地路线图` : '通用AI落地规划参考'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, overflowX: 'auto' }} className="space-scroll">
|
||||||
|
{[
|
||||||
|
{ phase: '阶段一', title: '现状诊断', duration: '1-2个月', color: '#ffb340', items: ['AI成熟度评估', '数据资产盘点', '场景识别优先级'], icon: 'analytics' as TechIconName },
|
||||||
|
{ phase: '阶段二', title: '需求精化', duration: '1-3个月', color: '#00d4ff', items: ['AI翻译官对接', '场景可行性分析', '技术方案评审'], icon: 'target' as TechIconName },
|
||||||
|
{ phase: '阶段三', title: '试点验证', duration: '3-6个月', color: '#7b61ff', items: ['小规模试点部署', 'ROI效果追踪', '方案迭代优化'], icon: 'lab' as TechIconName },
|
||||||
|
{ phase: '阶段四', title: '规模推广', duration: '6-12个月', color: '#00e599', items: ['扩大应用范围', '建立AI运维体系', '复制成功经验'], icon: 'deploy' as TechIconName },
|
||||||
|
].map(stage => (
|
||||||
|
<div key={stage.phase} style={{ minWidth: 160, padding: '10px 12px', borderRadius: 10, background: `${stage.color}08`, border: `1px solid ${stage.color}25`, flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||||
|
<TechIcon name={stage.icon} color={stage.color} size={20} />
|
||||||
|
<div>
|
||||||
|
<div style={{ color: stage.color, fontSize: 12, fontWeight: 700 }}>{stage.phase}</div>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 13, fontWeight: 600 }}>{stage.title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.45)', fontSize: 11, marginBottom: 8 }}>周期 {stage.duration}</div>
|
||||||
|
{stage.items.map(item => (
|
||||||
|
<div key={item} style={{ color: 'rgba(255,255,255,0.75)', fontSize: 12, lineHeight: 1.6 }}>· {item}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── TAB: AI优惠券 ─── */}
|
||||||
|
{tab === 'coupons' && (
|
||||||
|
<div style={{ padding: '10px 24px 14px' }}>
|
||||||
|
<div style={{ color: '#a8d8ff', fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}><TechIcon name="coupon" color="#00d4ff" size={17} />温州市AI+制造优惠券({COUPONS.length}种可用)</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, overflowX: 'auto' }} className="space-scroll">
|
||||||
|
{COUPONS.map(coupon => {
|
||||||
|
const cfg = couponTypeConfig[coupon.type];
|
||||||
|
const isClaimed = claimedCoupons.has(coupon.id);
|
||||||
|
return (
|
||||||
|
<div key={coupon.id} style={{
|
||||||
|
minWidth: 170, padding: '10px 12px', borderRadius: 10, flexShrink: 0,
|
||||||
|
background: `${coupon.color}08`, border: `1px solid ${coupon.color}${isClaimed ? '60' : '25'}`,
|
||||||
|
opacity: isClaimed ? 0.7 : 1, transition: 'all 0.18s ease',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||||
|
<TechIcon name={(coupon.icon as TechIconName) || 'coupon'} color={coupon.color} size={22} />
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 4, background: cfg.bg, color: cfg.color, fontSize: 10, fontWeight: 700 }}>{cfg.label}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 13, fontWeight: 700 }}>{coupon.title}</div>
|
||||||
|
<div style={{ color: coupon.color, fontSize: 18, fontWeight: 800, margin: '4px 0 2px' }}>{coupon.value}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11, lineHeight: 1.4, marginBottom: 6 }}>{coupon.condition}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: 10, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 5 }}><TechIcon name="calendar" color="rgba(255,255,255,0.4)" size={12} />{coupon.expiry} · 余{coupon.remaining}张</div>
|
||||||
|
<button onClick={() => setClaimedCoupons(prev => { const s = new Set(prev); isClaimed ? s.delete(coupon.id) : s.add(coupon.id); return s; })} style={{
|
||||||
|
width: '100%', padding: '5px 0', borderRadius: 6, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
background: isClaimed ? `${coupon.color}18` : `${coupon.color}22`,
|
||||||
|
border: `1px solid ${coupon.color}40`, color: isClaimed ? coupon.color : '#fff',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}>
|
||||||
|
{isClaimed ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}><TechIcon name="check" color={coupon.color} size={13} />已领取</span> : '立即领取'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import type { Order } from '../data/orders';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import { useChatSSE } from '../services/hooks/useChatSSE';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: number;
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
thinking: string;
|
||||||
|
content: string;
|
||||||
|
thinkingDone: boolean;
|
||||||
|
done: boolean;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BottomCostBarProps {
|
||||||
|
selectedOrder: Order | null;
|
||||||
|
onHide?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeStr = () => {
|
||||||
|
const now = new Date();
|
||||||
|
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUICK_CMDS = ['理论与实际成本对比', '成本测算模拟', '关键工艺环节', '成本异常分析', '分析成本偏差', '报价建议', '降本措施', '毛利对比', '亏损原因'];
|
||||||
|
|
||||||
|
function fmt(n: number) { return Math.round(n).toLocaleString('zh-CN'); }
|
||||||
|
|
||||||
|
const EQ_OPTS: Record<string, { costM: number; timeM: number; defAdj: number }[]> = {
|
||||||
|
'下料': [{ costM: 1.00, timeM: 1.0, defAdj: 0 }, { costM: 1.09, timeM: 1.5, defAdj: 0.005 }, { costM: 1.03, timeM: 1.25, defAdj: 0.002 }],
|
||||||
|
'车加工': [{ costM: 1.00, timeM: 1.0, defAdj: 0 }, { costM: 1.27, timeM: 1.4, defAdj: 0.010 }, { costM: 1.09, timeM: 1.2, defAdj: 0.003 }],
|
||||||
|
'热处理': [{ costM: 1.00, timeM: 1.0, defAdj: 0 }, { costM: 1.47, timeM: 1.33, defAdj: 0.030 }, { costM: 1.20, timeM: 1.17, defAdj: 0.006 }],
|
||||||
|
'磨加工': [{ costM: 1.00, timeM: 1.0, defAdj: 0 }, { costM: 1.33, timeM: 1.33, defAdj: 0.032 }, { costM: 1.11, timeM: 1.17, defAdj: 0.019 }],
|
||||||
|
'装配': [{ costM: 1.00, timeM: 1.0, defAdj: 0 }, { costM: 1.25, timeM: 1.4, defAdj: 0.028 }, { costM: 1.08, timeM: 1.2, defAdj: 0.008 }],
|
||||||
|
'检验': [{ costM: 1.00, timeM: 1.0, defAdj: 0 }, { costM: 1.38, timeM: 1.23, defAdj: 0 }, { costM: 1.11, timeM: 1.08, defAdj: 0 }],
|
||||||
|
};
|
||||||
|
const EQ_DEF: Record<string, string> = { '下料': '自动线', '车加工': '自动线', '热处理': '自动线', '磨加工': '自动线', '装配': '自动线', '检验': '自动线' };
|
||||||
|
|
||||||
|
export default function BottomCostBar({ selectedOrder, onHide }: BottomCostBarProps) {
|
||||||
|
const { tokens } = useTheme();
|
||||||
|
const { send: sendSSE } = useChatSSE('order-cost-analysis');
|
||||||
|
const [messages, setMessages] = useState<Message[]>([{
|
||||||
|
id: 0, role: 'ai',
|
||||||
|
thinking: '',
|
||||||
|
content: '您好!我是雷勃动力传动 AI 轴承成本分析助手。\n\n我可以帮您:\n• 分析轴承订单三种成本口径(原材料/人工/管理)的偏差原因\n• 识别外圈、内圈、滚针等组件的成本异常项并给出降本建议\n• 对比出货收入与各维度成本,计算轴承订单毛利与净利润\n• 将管理成本与报价成本细项做对比,辅助销售报价决策\n• 评估不同产线(自动线/半自动线/混合设备)的加工成本与良率差异\n\n请从左侧选择一个轴承订单,或直接提问,例如:\n"为什么ORD003亏损?" / "哪条产线成本最低?"',
|
||||||
|
thinkingDone: true, done: true, time: getTimeStr(),
|
||||||
|
}]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [showSim, setShowSim] = useState(false);
|
||||||
|
const [simEquip, setSimEquip] = useState<Record<string, string>>({});
|
||||||
|
const [simResult, setSimResult] = useState<{ thinking: string; content: string; thinkingDone: boolean; done: boolean } | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}, [messages, isTyping]);
|
||||||
|
|
||||||
|
const streamText = useCallback((msgId: number, field: 'thinking' | 'content', fullText: string, charDelay: number, onDone?: () => void) => {
|
||||||
|
let idx = 0;
|
||||||
|
const tick = () => {
|
||||||
|
idx += Math.floor(Math.random() * 3) + 1;
|
||||||
|
const slice = fullText.slice(0, Math.min(idx, fullText.length));
|
||||||
|
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, [field]: slice } : m));
|
||||||
|
if (idx < fullText.length) {
|
||||||
|
streamTimerRef.current = setTimeout(tick, charDelay);
|
||||||
|
} else {
|
||||||
|
if (field === 'thinking') setMessages(prev => prev.map(m => m.id === msgId ? { ...m, thinkingDone: true } : m));
|
||||||
|
else setMessages(prev => prev.map(m => m.id === msgId ? { ...m, done: true } : m));
|
||||||
|
onDone?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
streamTimerRef.current = setTimeout(tick, charDelay);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildReply = useCallback((text: string): { thinking: string; content: string } => {
|
||||||
|
const o = selectedOrder;
|
||||||
|
|
||||||
|
if (text.includes('偏差') || text.includes('异常') || text.includes('差异')) {
|
||||||
|
if (!o) return {
|
||||||
|
thinking: '分析成本偏差指标…',
|
||||||
|
content: '请先选择一个订单,我将为您详细分析该订单的原材料成本、直接人工成本和管理成本之间的偏差。',
|
||||||
|
};
|
||||||
|
const stdRow = o.rows.find(r => r.type === '原材料成本')!;
|
||||||
|
const purRow = o.rows.find(r => r.type === '直接人工成本')!;
|
||||||
|
const useRow = o.rows.find(r => r.type === '管理成本')!;
|
||||||
|
const purDiff = purRow.totalCost - stdRow.totalCost;
|
||||||
|
const useDiff = useRow.totalCost - stdRow.totalCost;
|
||||||
|
return {
|
||||||
|
thinking: `调取订单 ${o.orderNo} 的三维成本数据…\n计算直接人工成本 vs 原材料成本偏差…\n计算管理成本 vs 原材料成本偏差…\n识别关键异常项…`,
|
||||||
|
content: `订单 ${o.orderNo} · ${o.customer} 成本偏差分析\n\n一、直接人工成本 vs 原材料成本\n• 总偏差:${purDiff >= 0 ? '+' : ''}${fmt(purDiff)} 元(${(purDiff / stdRow.totalCost * 100).toFixed(1)}%)\n• 直接材料偏差:${fmt(purRow.directMaterial - stdRow.directMaterial)} 元\n• 直接人工偏差:${fmt(purRow.directLabor - stdRow.directLabor)} 元\n• 采购偏差${Math.abs(purDiff) > 5000 ? '较大,需关注供应商价格执行情况' : '在合理范围内'}\n\n二、管理成本 vs 原材料成本\n• 总偏差:${useDiff >= 0 ? '+' : ''}${fmt(useDiff)} 元(${(useDiff / stdRow.totalCost * 100).toFixed(1)}%)\n• 直接材料领用偏差:${fmt(useRow.directMaterial - stdRow.directMaterial)} 元\n• 直接人工偏差:${fmt(useRow.directLabor - stdRow.directLabor)} 元\n${useDiff > 0 ? '• 管理成本超标,需核查:①领料单与实际用量是否一致;②生产废料率是否偏高;③工时核算是否准确' : '• 管理成本节约,生产效率优于标准,建议更新原材料成本'}\n\n${o.note || '建议对偏差超过标准5%的科目进行专项核查。'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('亏损') || text.includes('原因')) {
|
||||||
|
if (!o) return {
|
||||||
|
thinking: '分析亏损原因…',
|
||||||
|
content: '请先选择一个订单进行亏损分析。您可以在左侧选择风险等级为"危险"的订单进行重点分析。',
|
||||||
|
};
|
||||||
|
const useRow = o.rows.find(r => r.type === '管理成本')!;
|
||||||
|
const stdRow = o.rows.find(r => r.type === '原材料成本')!;
|
||||||
|
return {
|
||||||
|
thinking: `分析订单 ${o.orderNo} 亏损成因…\n对比三维成本数据…\n识别关键亏损驱动因素…`,
|
||||||
|
content: `订单 ${o.orderNo} · ${o.customer} ${useRow.netProfit < 0 ? '亏损原因分析' : '利润分析'}\n\n盈利概况(管理成本口径):\n• 销售收入:${fmt(useRow.revenue)} 元\n• 实际制造成本:${fmt(useRow.totalCost)} 元(毛利率 ${(useRow.grossMargin * 100).toFixed(1)}%)\n• 管销研费用:${fmt(useRow.mgmtExpense)} 元\n• 净利润:${fmt(useRow.netProfit)} 元(净利率 ${(useRow.netMargin * 100).toFixed(1)}%)\n\n${useRow.netProfit < 0 ? `亏损原因分析:\n① 报价偏低:销售单价 ¥${o.rows[0].unitPrice},对应制造成本摊分后利润空间不足\n② 实际管理成本(${fmt(useRow.totalCost)} 元)超出原材料成本(${fmt(stdRow.totalCost)} 元)${fmt(useRow.totalCost - stdRow.totalCost)} 元\n③ 管销研费用固定支出(${fmt(useRow.mgmtExpense)} 元)较高,压缩了净利润空间\n\n建议措施:\n• 下期同类订单报价上调至少 ${((0.08 - useRow.netMargin) * 100).toFixed(0)}% 以确保5%+净利润空间\n• 重点核查领料损耗率,目标降低管理成本至直接人工成本±2%范围内` : `订单盈利健康,净利率${(useRow.netMargin * 100).toFixed(1)}%处于${useRow.netMargin >= 0.1 ? '良好' : '偏低'}水平。`}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('降本') || text.includes('降低成本') || text.includes('措施')) {
|
||||||
|
return {
|
||||||
|
thinking: `分析制造业成本降低路径…\n结合订单成本结构…\n生成降本措施建议…`,
|
||||||
|
content: `${o ? `${o.customer} 订单降本措施` : '制造业订单降本措施'}\n\n一、直接材料降本\n• 采购集中度:同品类面料、辅料集中批量采购,争取量价优惠\n• 供应商竞标:对偏差超5%的物料实施双源采购,引入竞争机制\n• 领用管控:建立领料配额制度,减少超额领料,控制废料率\n• 标准修订:定期与实际用量对比,优化单件用料标准\n\n二、直接人工降本\n• 工时分析:对比标准工时与实际工时,识别效率洼地\n• 工序优化:推进精益生产,减少等待、搬运等非增值作业\n• 技能培训:提升操作工艺水平,降低次品率与返工成本\n\n三、制造费用优化\n• 设备利用率:提升产线利用率,分摊单件制造费用\n• 能耗管理:实施能源计量与节能改造\n• 维修费控制:推行预防性维护,减少突发停机\n\n四、原材料成本更新\n• 每季度复核原材料成本,确保标准与实际接轨,减少无效偏差分析`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('报价') || text.includes('定价')) {
|
||||||
|
if (!o) return {
|
||||||
|
thinking: '分析报价策略…',
|
||||||
|
content: '请选择订单后,我将基于该订单的实际成本数据为您提供报价优化建议。',
|
||||||
|
};
|
||||||
|
const useRow = o.rows.find(r => r.type === '管理成本')!;
|
||||||
|
const targetMargin = 0.08;
|
||||||
|
const suggestedRevenue = useRow.totalCost + useRow.mgmtExpense;
|
||||||
|
const suggestedRevenueWithTarget = suggestedRevenue / (1 - targetMargin);
|
||||||
|
const suggestedUnitPrice = suggestedRevenueWithTarget / useRow.qty;
|
||||||
|
return {
|
||||||
|
thinking: `基于管理成本口径计算合理报价区间…\n设定目标净利率 8%…\n生成报价建议…`,
|
||||||
|
content: `${o.customer} · ${o.productCategory} 报价优化建议\n\n当前报价:¥${o.rows[0].unitPrice} / 件\n当前净利率:${(useRow.netMargin * 100).toFixed(1)}%(管理成本口径)\n\n目标定价测算(目标净利率 8%):\n• 领用制造成本:${fmt(useRow.totalCost)} 元\n• 管销研费用:${fmt(useRow.mgmtExpense)} 元\n• 目标收入:${fmt(Math.round(suggestedRevenueWithTarget))} 元\n• 建议单价:¥${suggestedUnitPrice.toFixed(4)} / 件(当前 +${((suggestedUnitPrice / o.rows[0].unitPrice - 1) * 100).toFixed(1)}%)\n\n报价策略建议:\n① 对利润率已低于5%的${o.channel}订单,下期谈判时争取调价空间\n② 可将直接人工成本节省部分(${fmt(o.rows.find(r => r.type === '直接人工成本')!.totalCost - useRow.totalCost)}元)纳入报价谈判筹码\n③ 建立"成本+目标利润"的报价底线机制,避免低于保本价接单`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('毛利') || text.includes('对比')) {
|
||||||
|
return {
|
||||||
|
thinking: `汇总全部订单毛利数据…\n按客户/渠道/品类分组…\n生成对比分析…`,
|
||||||
|
content: `全部订单毛利对比(管理成本口径)\n\n${ORDERS_SUMMARY}\n\n整体建议:\n• 高毛利订单(≥15%):日本Hakuba、百联传媒,可作为定价标杆\n• 预警订单(5%-15%):ATB、PhotoPro、Amazon US,重点监控成本偏差\n• 亏损/危险订单:万博传媒(管理成本亏损)、Kenko Korea(三维成本均亏),需专项降本或调整报价\n\n渠道盈利对比:\n• 外销-B2B平均净利率最高,但日韩绿幕品类定价偏低\n• 内销直销华南区风险最大(管理成本超标严重)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 新增:理论成本与实际成本对比分析 ═══
|
||||||
|
if (text.includes('理论与实际') || text === '理论与实际成本对比') {
|
||||||
|
if (!o) return { thinking: '等待订单选择…', content: '请先选择一个订单,我将为您进行理论成本(标准BOM)与实际成本(领用消耗)的深度对比分析。' };
|
||||||
|
const stdRow = o.rows.find(r => r.type === '原材料成本')!;
|
||||||
|
const useRow = o.rows.find(r => r.type === '管理成本')!;
|
||||||
|
const matDiff = useRow.directMaterial - stdRow.directMaterial;
|
||||||
|
const labDiff = useRow.directLabor - stdRow.directLabor;
|
||||||
|
const totalDiff = useRow.totalCost - stdRow.totalCost;
|
||||||
|
const matDiffPct = matDiff / stdRow.directMaterial * 100;
|
||||||
|
const labDiffPct = labDiff / stdRow.directLabor * 100;
|
||||||
|
const bd = o.costBreakdown || [];
|
||||||
|
const matItems = bd.find(c => c.category === '原材料')?.items || [];
|
||||||
|
const compAnalysis = matItems.map(it => {
|
||||||
|
const flag = Math.abs(it.usageVsStdPct) > 0.03 ? ' ⚠️' : Math.abs(it.usageVsStdPct) > 0.015 ? ' ⚡' : ' ✅';
|
||||||
|
return ` ${it.label}:标准 ¥${fmt(it.stdCost)} → 实际 ¥${fmt(it.usageCost)}(${it.usageVsStdAmt >= 0 ? '+' : ''}¥${fmt(it.usageVsStdAmt)},${(it.usageVsStdPct * 100).toFixed(1)}%)${flag}`;
|
||||||
|
}).join('\n');
|
||||||
|
const abnormalItems = matItems.filter(it => Math.abs(it.usageVsStdPct) > 0.03);
|
||||||
|
const healthyItems = matItems.filter(it => Math.abs(it.usageVsStdPct) <= 0.015);
|
||||||
|
return {
|
||||||
|
thinking: `加载订单 ${o.orderNo} BOM标准用量数据…\n提取ERP领料单实际消耗…\n逐组件计算偏差率…\n识别异常组件(偏差>3%)…\n分析损耗归因…`,
|
||||||
|
content: `📊 ${o.customer}(${o.orderNo})理论成本 vs 实际成本深度对比\n\n━━ 总量偏差概览 ━━\n• 材料成本:标准 ¥${fmt(stdRow.directMaterial)} → 实际 ¥${fmt(useRow.directMaterial)}(${matDiff >= 0 ? '+' : ''}¥${fmt(matDiff)},${matDiffPct >= 0 ? '+' : ''}${matDiffPct.toFixed(2)}%)\n• 人工成本:标准 ¥${fmt(stdRow.directLabor)} → 实际 ¥${fmt(useRow.directLabor)}(${labDiff >= 0 ? '+' : ''}¥${fmt(labDiff)},${labDiffPct >= 0 ? '+' : ''}${labDiffPct.toFixed(2)}%)\n• 制造成本合计:标准 ¥${fmt(stdRow.totalCost)} → 实际 ¥${fmt(useRow.totalCost)}(${totalDiff >= 0 ? '+' : ''}¥${fmt(totalDiff)})\n\n━━ 组件级偏差明细 ━━\n${compAnalysis}\n\n━━ 业务洞察 ━━\n${abnormalItems.length > 0 ? `⚠️ 异常组件(偏差>3%):${abnormalItems.map(it => `${it.label}(${(it.usageVsStdPct * 100).toFixed(1)}%)`).join('、')}\n→ 建议:立即核查这些组件的领料单与实际BOM用量,排查是否存在超额领料或工艺损耗偏高\n` : '✅ 所有组件偏差均在3%以内,材料管控处于良好水平\n'}${labDiffPct < -5 ? `💡 人工效率优异(节省${Math.abs(labDiffPct).toFixed(1)}%),建议将本订单的工艺参数与操作规范沉淀为标杆,推广至同类产品线\n` : labDiffPct > 5 ? `⚠️ 人工超标${labDiffPct.toFixed(1)}%,需核查:①加班工时占比 ②设备故障停机时间 ③新员工培训周期\n` : ''}${healthyItems.length >= matItems.length * 0.7 ? '📈 整体管控良好,可作为同品类订单的标准成本校准参考基准' : '📈 建议对偏差>2%的组件建立月度复盘机制,持续追踪改善效果'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 新增:换线成本模拟测算 ═══
|
||||||
|
if (text.includes('换线') || text.includes('成本测算') || text.includes('产线成本')) {
|
||||||
|
if (!o) return { thinking: '等待订单选择…', content: '请先选择一个订单,再打开成本测算模拟器。' };
|
||||||
|
setShowSim(true);
|
||||||
|
setSimEquip({ ...EQ_DEF });
|
||||||
|
setSimResult(null);
|
||||||
|
setExpanded(true);
|
||||||
|
return { thinking: '', content: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 新增:影响成本的关键工艺环节 ═══
|
||||||
|
if (text.includes('关键工艺') || text.includes('工艺环节')) {
|
||||||
|
if (!o) return { thinking: '等待订单选择…', content: '请先选择一个订单,我将分析影响该订单成本的关键工艺环节。' };
|
||||||
|
const lines = o.productionLines || [];
|
||||||
|
if (lines.length === 0) return { thinking: '检查工序数据…', content: '该订单暂无工序级数据。' };
|
||||||
|
const allSteps: { name: string; cost: number; duration: number; defectRate: number; hasIssue: boolean; issueDesc?: string; line: string }[] = [];
|
||||||
|
lines.forEach(l => l.steps.forEach(s => allSteps.push({ ...s, line: l.name })));
|
||||||
|
const stepMap = new Map<string, { name: string; cost: number; duration: number; defectRate: number; hasIssue: boolean; issueDesc?: string; lines: string[] }>();
|
||||||
|
allSteps.forEach(s => {
|
||||||
|
const ex = stepMap.get(s.name);
|
||||||
|
if (ex) { ex.cost = Math.round((ex.cost + s.cost) / 2); ex.duration = +(ex.duration + s.duration) / 2; ex.defectRate = Math.max(ex.defectRate, s.defectRate); ex.hasIssue = ex.hasIssue || s.hasIssue; if (s.issueDesc) ex.issueDesc = s.issueDesc; ex.lines.push(s.line); }
|
||||||
|
else stepMap.set(s.name, { ...s, lines: [s.line] });
|
||||||
|
});
|
||||||
|
const steps = [...stepMap.values()].sort((a, b) => (b.cost * b.defectRate * 10) - (a.cost * a.defectRate * 10));
|
||||||
|
const totalCostAll = steps.reduce((s, st) => s + st.cost, 0);
|
||||||
|
const analysis = steps.map(st => {
|
||||||
|
const costPct = (st.cost / totalCostAll * 100).toFixed(1);
|
||||||
|
const yieldImpact = (st.defectRate * 100).toFixed(1);
|
||||||
|
const downstream = steps.filter(x => steps.indexOf(x) > steps.indexOf(st)).length;
|
||||||
|
const reworkCost = Math.round(st.cost * st.defectRate * (1 + downstream * 0.3));
|
||||||
|
const level = st.hasIssue ? '🔴 高风险' : st.defectRate > 0.02 ? '🟡 关注' : st.defectRate > 0.005 ? '🟢 正常' : '✅ 优秀';
|
||||||
|
return ` ${st.name}:¥${fmt(st.cost)}(占${costPct}%)| ${st.duration}h | 不良率${yieldImpact}% | ${level}\n 级联返工成本 ≈¥${fmt(reworkCost)} | 影响下游${downstream}道工序${st.issueDesc ? `\n ⚠️ 问题:${st.issueDesc}(${st.lines.join('/')})` : ''}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
const topIssue = steps.find(s => s.hasIssue);
|
||||||
|
const topCost = steps[0];
|
||||||
|
return {
|
||||||
|
thinking: `加载 ${o.productCategory} 全工序数据…\n计算各工序成本贡献度与缺陷级联影响…\n识别瓶颈工序与改善优先级…\n生成工艺改善路线图…`,
|
||||||
|
content: `📊 ${o.customer}(${o.orderNo})关键工艺环节分析\n\n━━ 工序成本与质量影响排序 ━━\n${analysis}\n\n━━ 关键结论 ━━\n🎯 成本影响最大工序:${topCost.name}(¥${fmt(topCost.cost)},占${(topCost.cost / totalCostAll * 100).toFixed(1)}%)\n → ${topCost.defectRate > 0.02 ? `不良率${(topCost.defectRate * 100).toFixed(1)}%偏高,每降低1%不良率可节省约 ¥${fmt(topCost.cost * 0.01)} / 批次` : '质量表现良好,维持当前管控水平'}\n${topIssue ? `\n🔴 当前最高风险工序:${topIssue.name}(${topIssue.issueDesc || '存在质量隐患'})\n → 该工序缺陷会产生级联效应,影响下游${steps.filter(x => steps.indexOf(x) > steps.indexOf(topIssue)).length}道工序\n → 建议优先改善,预期收益:返工成本降低 ¥${fmt(Math.round(topIssue.cost * topIssue.defectRate * 2))} / 批次\n` : ''}\n━━ 改善优先级 ━━\n1️⃣ ${topIssue ? `${topIssue.name} — 解决「${topIssue.issueDesc || '质量问题'}」,ROI最高` : `${topCost.name} — 效率优化,降低单件成本`}\n2️⃣ ${steps.find(s => s.duration >= Math.max(...steps.map(x => x.duration)))?.name || topCost.name} — 瓶颈工序,提升效率可缩短交期\n3️⃣ 其余工序 — 维持管控,定期校准工艺参数\n\n💡 建议对高风险工序实施SPC统计过程控制,区分偶发性问题与系统性问题,针对性制定改善方案`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 新增:成本异常分析 ═══
|
||||||
|
if (text.includes('成本异常')) {
|
||||||
|
if (!o) return { thinking: '等待订单选择…', content: '请先选择一个订单,我将对该订单进行多维度成本异常扫描。' };
|
||||||
|
const stdRow = o.rows.find(r => r.type === '原材料成本')!;
|
||||||
|
const useRow = o.rows.find(r => r.type === '管理成本')!;
|
||||||
|
const matDiff = useRow.directMaterial - stdRow.directMaterial;
|
||||||
|
const labDiff = useRow.directLabor - stdRow.directLabor;
|
||||||
|
const matPct = matDiff / stdRow.directMaterial * 100;
|
||||||
|
const labPct = labDiff / stdRow.directLabor * 100;
|
||||||
|
const bd = o.costBreakdown || [];
|
||||||
|
const matItems = bd.find(c => c.category === '原材料')?.items || [];
|
||||||
|
const anomalies = matItems.filter(it => Math.abs(it.usageVsStdPct) > 0.03).sort((a, b) => Math.abs(b.usageVsStdPct) - Math.abs(a.usageVsStdPct));
|
||||||
|
const purAnomalies = matItems.filter(it => Math.abs(it.purchaseVsStdPct) > 0.04);
|
||||||
|
const lines = o.productionLines || [];
|
||||||
|
const issueSteps = lines.flatMap(l => l.steps.filter(s => s.hasIssue).map(s => `${l.name}-${s.name}:${s.issueDesc || '异常'}`));
|
||||||
|
const riskScore = (Math.abs(matPct) > 5 ? 2 : Math.abs(matPct) > 2 ? 1 : 0) + (Math.abs(labPct) > 10 ? 2 : Math.abs(labPct) > 5 ? 1 : 0) + (anomalies.length >= 3 ? 2 : anomalies.length >= 1 ? 1 : 0) + (o.riskLevel === 'danger' ? 2 : o.riskLevel === 'warning' ? 1 : 0) + (issueSteps.length >= 3 ? 2 : issueSteps.length >= 1 ? 1 : 0);
|
||||||
|
const riskLabel = riskScore >= 6 ? '🚨 高风险' : riskScore >= 3 ? '⚠️ 中风险' : '✅ 低风险';
|
||||||
|
return {
|
||||||
|
thinking: `扫描订单 ${o.orderNo} 多维度成本数据…\n检测材料/人工/制造费用异常信号…\n交叉验证产线工序问题…\n计算综合风险评分…\n生成异常处置建议…`,
|
||||||
|
content: `📊 ${o.customer}(${o.orderNo})成本异常分析报告\n\n━━ 风险概览 ━━\n综合风险评分:${riskScore}/10 ${riskLabel}\n${o.riskLevel === 'danger' ? '🚨 系统标记:高风险订单,建议管理层关注' : o.riskLevel === 'warning' ? '⚠️ 系统标记:预警订单,需加强过程监控' : '✅ 系统标记:正常订单'}\n\n━━ 异常信号扫描 ━━\n1. 材料成本偏差:${matPct >= 0 ? '+' : ''}${matPct.toFixed(2)}%(¥${fmt(matDiff)})${Math.abs(matPct) > 5 ? ' 🔴 超标' : Math.abs(matPct) > 2 ? ' 🟡 关注' : ' 🟢 正常'}\n2. 人工成本偏差:${labPct >= 0 ? '+' : ''}${labPct.toFixed(2)}%(¥${fmt(labDiff)})${Math.abs(labPct) > 10 ? ' 🔴 严重超标' : Math.abs(labPct) > 5 ? ' 🟡 关注' : ' 🟢 正常'}\n3. 组件异常数量:${anomalies.length}/${matItems.length} 项偏差>3%${anomalies.length >= 3 ? ' 🔴 多项异常' : anomalies.length >= 1 ? ' 🟡 个别异常' : ' 🟢 全部正常'}\n${purAnomalies.length > 0 ? `4. 采购价格异常:${purAnomalies.map(it => `${it.label}(${(it.purchaseVsStdPct * 100).toFixed(1)}%)`).join('、')} 🟡 供应商议价\n` : ''}\n${anomalies.length > 0 ? `━━ 异常组件详情 ━━\n${anomalies.map(it => `• ${it.label}:实际 ¥${fmt(it.usageCost)} vs 标准 ¥${fmt(it.stdCost)}(偏差${(it.usageVsStdPct * 100).toFixed(1)}%,金额 ¥${fmt(Math.abs(it.usageVsStdAmt))})\n → 可能原因:${it.usageVsStdAmt > 0 ? '领料超额/工艺损耗偏高/废料率上升' : '退料节省/工艺优化/BOM修正'}`).join('\n')}\n` : ''}${issueSteps.length > 0 ? `━━ 产线工艺异常 ━━\n${issueSteps.map(s => `⚠️ ${s}`).join('\n')}\n` : ''}\n━━ 处置建议 ━━\n${riskScore >= 6 ? '🔴 高风险处置:\n① 立即冻结该品类新订单报价,重新核算成本基线\n② 安排生产/采购/质量三方联合排查会议\n③ 对异常组件实施100%来料检验' : riskScore >= 3 ? '⚠️ 中风险处置:\n① 标记为下期成本复盘重点对象\n② 对偏差>3%的组件追溯领料单据\n③ 加强过程巡检频次' : '✅ 低风险:维持常规管控,月度跟踪趋势'}\n\n💡 建议建立「成本异常预警看板」,对偏差>5%的科目自动触发审批流程`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用回复
|
||||||
|
return {
|
||||||
|
thinking: `解析问题关键词…\n结合订单成本分析专业知识…\n生成回答…`,
|
||||||
|
content: `${o ? `当前订单:${o.customer}(${o.orderNo})
|
||||||
|
品类:${o.productCategory} | 渠道:${o.channel}
|
||||||
|
|
||||||
|
` : ''}您询问了关于:"${text}"
|
||||||
|
|
||||||
|
建议使用快捷指令获取更精准的分析:
|
||||||
|
• "理论与实际成本对比" — BOM标准 vs 领用消耗深度对比
|
||||||
|
• "成本测算模拟" — 交互式选择设备,测算生产成本
|
||||||
|
• "关键工艺环节" — 识别成本影响最大的工序
|
||||||
|
• "成本异常分析" — 多维度异常扫描与风险评分
|
||||||
|
• "分析成本偏差" — 查看三维成本的差异明细
|
||||||
|
• "报价建议" — 基于实际成本给出定价优化方案
|
||||||
|
|
||||||
|
直接提问,或点击快捷指令开始分析。`,
|
||||||
|
};
|
||||||
|
}, [selectedOrder]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback((text: string) => {
|
||||||
|
if (!text.trim() || isTyping) return;
|
||||||
|
setMessages(prev => [...prev, { id: Date.now(), role: 'user', thinking: '', content: text, thinkingDone: true, done: true, time: getTimeStr() }]);
|
||||||
|
setInput('');
|
||||||
|
setIsTyping(true);
|
||||||
|
|
||||||
|
const aiMsgId = Date.now() + 1;
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
id: aiMsgId, role: 'ai', thinking: '', content: '',
|
||||||
|
thinkingDone: false, done: false, time: getTimeStr(),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const ctx: Record<string, unknown> = {};
|
||||||
|
if (selectedOrder) {
|
||||||
|
ctx.selectedOrderNo = selectedOrder.orderNo;
|
||||||
|
ctx.customer = selectedOrder.customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gotResponse = false;
|
||||||
|
|
||||||
|
sendSSE(text, ctx, {
|
||||||
|
onToken: (token) => {
|
||||||
|
gotResponse = true;
|
||||||
|
setMessages(prev => prev.map(m => m.id === aiMsgId ? {
|
||||||
|
...m, thinkingDone: true, content: m.content + token,
|
||||||
|
} : m));
|
||||||
|
},
|
||||||
|
onToolStart: (tool, toolInput) => {
|
||||||
|
setMessages(prev => prev.map(m => m.id === aiMsgId ? {
|
||||||
|
...m, thinking: m.thinking + `调用工具 ${tool}(${toolInput.slice(0, 80)})…\n`,
|
||||||
|
} : m));
|
||||||
|
},
|
||||||
|
onToolEnd: (tool, toolOutput) => {
|
||||||
|
setMessages(prev => prev.map(m => m.id === aiMsgId ? {
|
||||||
|
...m, thinking: m.thinking + `${tool} 返回结果(${toolOutput.length}字符)\n`,
|
||||||
|
} : m));
|
||||||
|
},
|
||||||
|
onDone: () => {
|
||||||
|
setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, thinkingDone: true, done: true } : m));
|
||||||
|
setIsTyping(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// SSE 失败,回退到 mock
|
||||||
|
if (!gotResponse) {
|
||||||
|
const { thinking, content } = buildReply(text);
|
||||||
|
setMessages(prev => prev.map(m => m.id === aiMsgId ? {
|
||||||
|
...m, thinking: '', content: '', thinkingDone: false, done: false,
|
||||||
|
} : m));
|
||||||
|
streamText(aiMsgId, 'thinking', thinking, 55, () => {
|
||||||
|
setTimeout(() => { streamText(aiMsgId, 'content', content, 35, () => setIsTyping(false)); }, 500);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, thinkingDone: true, done: true } : m));
|
||||||
|
setIsTyping(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [isTyping, selectedOrder, sendSSE, buildReply, streamText]);
|
||||||
|
|
||||||
|
const handleGenerateSim = useCallback(() => {
|
||||||
|
const o = selectedOrder;
|
||||||
|
if (!o) return;
|
||||||
|
const sels = { ...EQ_DEF, ...simEquip };
|
||||||
|
const lines = o.productionLines || [];
|
||||||
|
const baseLine = lines[0];
|
||||||
|
if (!baseLine) return;
|
||||||
|
const userMsg = `成本测算模拟:${baseLine.steps.map(s => `${s.name}→${sels[s.name]}`).join(' | ')}`;
|
||||||
|
sendMessage(userMsg);
|
||||||
|
setSimResult({ thinking: '', content: '', thinkingDone: false, done: false });
|
||||||
|
const dm = o.rows.find(r => r.type === '原材料成本')?.directMaterial ?? 0;
|
||||||
|
const qty = o.rows[0]?.qty ?? 1;
|
||||||
|
const revenue = o.rows[0]?.revenue ?? 0;
|
||||||
|
const mgmt = o.rows.find(r => r.type === '管理成本')?.mgmtExpense ?? 0;
|
||||||
|
let laborCost = 0, procTime = 0, procCost = 0;
|
||||||
|
baseLine.steps.forEach(step => {
|
||||||
|
const eq = sels[step.name] || '自动线';
|
||||||
|
const opts = (EQ_OPTS[step.name] || []).find((_e, i) => ['自动线', '半自动线', '混合设备'][i] === eq) || EQ_OPTS[step.name]?.[0] || { costM: 1, timeM: 1, defAdj: 0 };
|
||||||
|
procCost += Math.round(step.cost * opts.costM);
|
||||||
|
laborCost += Math.round(step.cost * 0.4 * opts.costM);
|
||||||
|
procTime += +(step.duration * opts.timeM).toFixed(1);
|
||||||
|
});
|
||||||
|
let yieldRate = 1;
|
||||||
|
baseLine.steps.forEach(step => {
|
||||||
|
const eq = sels[step.name] || '自动线';
|
||||||
|
const opts = (EQ_OPTS[step.name] || []).find((_e, i) => ['自动线', '半自动线', '混合设备'][i] === eq) || EQ_OPTS[step.name]?.[0] || { defAdj: 0 };
|
||||||
|
yieldRate *= (1 - step.defectRate - opts.defAdj);
|
||||||
|
});
|
||||||
|
const oh = o.rows.find(r => r.type === '原材料成本')?.manufacturingCost ?? 0;
|
||||||
|
const totalMC = laborCost + oh + dm;
|
||||||
|
const gp = revenue - totalMC;
|
||||||
|
const gm = revenue > 0 ? gp / revenue : 0;
|
||||||
|
const np = gp - mgmt;
|
||||||
|
const nm = revenue > 0 ? np / revenue : 0;
|
||||||
|
let origProc = 0, origYield = 1;
|
||||||
|
baseLine.steps.forEach(step => {
|
||||||
|
const opts = EQ_OPTS[step.name]?.[0] || { costM: 1, defAdj: 0 };
|
||||||
|
origProc += Math.round(step.cost * opts.costM);
|
||||||
|
origYield *= (1 - step.defectRate - opts.defAdj);
|
||||||
|
});
|
||||||
|
const costDiff = procCost - origProc;
|
||||||
|
const yieldDiff = (yieldRate - origYield) * 100;
|
||||||
|
const thinking = '初始化成本测算引擎…\n读取订单BOM与工艺路线数据…\n计算各工序设备选型成本系数…\n汇总直接材料、直接人工、制造费用…\n计算综合良率与良率修正成本…\n生成测算报告…';
|
||||||
|
const content = `📊 ${o.customer}(${o.orderNo})生产成本测算结果\n\n━━ 工艺配置 ━━\n${baseLine.steps.map(s => ` ${s.name}:${sels[s.name]}`).join('\n')}\n\n━━ 成本测算结果 ━━\n• 原材料成本:¥${fmt(dm)}\n• 直接人工成本:¥${fmt(laborCost)}(工时 ${procTime}h)\n• 制造费用:¥${fmt(oh)}\n• 制造成本合计:¥${fmt(totalMC)}\n• 综合良率:${(yieldRate * 100).toFixed(2)}%\n• 良率修正单件成本:¥${(totalMC / qty / yieldRate).toFixed(2)}\n\n━━ 盈利预估 ━━\n• 销售收入:¥${fmt(revenue)}\n• 毛利:¥${fmt(gp)}(毛利率 ${(gm * 100).toFixed(1)}%)\n• 管销研费用:¥${fmt(mgmt)}\n• 净利润:¥${fmt(np)}(净利率 ${(nm * 100).toFixed(1)}%)\n\n━━ 对比基准(全${EQ_DEF[baseLine.steps[0]?.name] || '自动线'}方案)━━\n• 加工成本差异:${costDiff >= 0 ? '+' : ''}¥${fmt(costDiff)}\n• 良率差异:${yieldDiff >= 0 ? '+' : ''}${yieldDiff.toFixed(2)}%\n${costDiff < 0 ? `\n💡 该配置较基准节省 ¥${fmt(Math.abs(costDiff))},良率${yieldDiff >= 0 ? '提升' : '下降'} ${Math.abs(yieldDiff).toFixed(2)}%,建议优先用于同类订单排产` : costDiff > 0 ? `\n⚠️ 该配置较基准增加 ¥${fmt(costDiff)},但良率${yieldDiff > 0 ? `提升 ${yieldDiff.toFixed(2)}%` : '无显著改善'},需评估良率收益是否覆盖增量成本` : '\n✅ 当前配置与基准一致'}`;
|
||||||
|
const rid = Date.now();
|
||||||
|
let idx = 0;
|
||||||
|
const tick = () => {
|
||||||
|
idx += Math.floor(Math.random() * 3) + 1;
|
||||||
|
const slice = thinking.slice(0, Math.min(idx, thinking.length));
|
||||||
|
setSimResult(prev => prev ? { ...prev, thinking: slice } : null);
|
||||||
|
if (idx < thinking.length) streamTimerRef.current = setTimeout(tick, 50);
|
||||||
|
else {
|
||||||
|
setSimResult(prev => prev ? { ...prev, thinkingDone: true } : null);
|
||||||
|
setTimeout(() => {
|
||||||
|
let ci = 0;
|
||||||
|
const ctick = () => {
|
||||||
|
ci += Math.floor(Math.random() * 3) + 1;
|
||||||
|
const cslice = content.slice(0, Math.min(ci, content.length));
|
||||||
|
setSimResult(prev => prev ? { ...prev, content: cslice } : null);
|
||||||
|
if (ci < content.length) streamTimerRef.current = setTimeout(ctick, 25);
|
||||||
|
else {
|
||||||
|
setSimResult(prev => prev ? { ...prev, done: true } : null);
|
||||||
|
setTimeout(() => {
|
||||||
|
setMessages(prev => [...prev, { id: rid, role: 'ai', thinking: '', content, thinkingDone: true, done: true, time: getTimeStr() }]);
|
||||||
|
setShowSim(false);
|
||||||
|
setIsTyping(false);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
streamTimerRef.current = setTimeout(ctick, 25);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
streamTimerRef.current = setTimeout(tick, 50);
|
||||||
|
}, [selectedOrder, simEquip, sendMessage, setMessages, setIsTyping]);
|
||||||
|
|
||||||
|
const chatHeight = expanded ? 380 : 90;
|
||||||
|
const ARC_H = 60;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-up" style={{ flexShrink: 0, paddingBottom: 0, position: 'relative', overflow: 'visible' }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{/* 弧形顶部 */}
|
||||||
|
<svg viewBox="0 0 1000 110" preserveAspectRatio="none" style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: ARC_H, pointerEvents: 'none', zIndex: 1, display: 'block', overflow: 'visible' }}>
|
||||||
|
<path d="M 0,110 C 150,-40 850,-40 1000,110 L 1000,110 L 0,110 Z" fill={tokens.bottomArcFill} />
|
||||||
|
<path d="M 0,110 C 150,-40 850,-40 1000,110" fill="none" stroke={tokens.svgStroke} strokeWidth="1.2" />
|
||||||
|
<circle cx="0" cy="110" r="5" fill={tokens.svgDot} />
|
||||||
|
<circle cx="1000" cy="110" r="5" fill={tokens.svgDot} />
|
||||||
|
<circle cx="500" cy="2" r="3" fill={tokens.svgStroke} />
|
||||||
|
<circle cx="200" cy="35" r="2" fill={tokens.svgStroke} />
|
||||||
|
<circle cx="800" cy="35" r="2" fill={tokens.svgStroke} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{ position: 'absolute', top: ARC_H - 1, left: 0, right: 0, bottom: 0, zIndex: 0, background: tokens.bottomBg, backdropFilter: 'blur(18px)', WebkitBackdropFilter: 'blur(18px)' }} />
|
||||||
|
|
||||||
|
<div style={{ position: 'relative', zIndex: 2, paddingTop: ARC_H }}>
|
||||||
|
{/* Tab 栏 */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, padding: '6px 20px 0', borderBottom: `1px solid ${tokens.borderAccent}33` }}>
|
||||||
|
<button style={{
|
||||||
|
padding: '6px 16px', borderRadius: '8px 8px 0 0', fontSize: 16, fontWeight: 700, cursor: 'pointer',
|
||||||
|
background: tokens.accentBg, color: tokens.accent,
|
||||||
|
borderBottom: `2px solid ${tokens.accent}`,
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
|
<circle cx="9" cy="9" r="7" stroke={tokens.accent} strokeWidth="1.5"/>
|
||||||
|
<path d="M 6,7 L 12,7 M 6,9 L 12,9 M 6,11 L 10,11" stroke={tokens.accent} strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
AI 智能问答
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 6 }}>
|
||||||
|
{selectedOrder && (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: tokens.accentBg, border: `1px solid ${tokens.borderAccent}`, color: tokens.accent, fontSize: 12 }}>
|
||||||
|
{selectedOrder.customer} · {selectedOrder.productCategory}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setExpanded(v => !v)} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 7, border: `1px solid ${tokens.border}`,
|
||||||
|
background: 'transparent', color: tokens.textMuted, fontSize: 12, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{expanded ? '收起' : '展开'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onHide} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 7, border: `1px solid ${tokens.border}`,
|
||||||
|
background: 'transparent', color: tokens.textMuted, fontSize: 12, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
隐藏
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 成本测算模拟器 */}
|
||||||
|
{showSim && (() => {
|
||||||
|
const o = selectedOrder;
|
||||||
|
const lines = o?.productionLines || [];
|
||||||
|
const baseLine = lines[0];
|
||||||
|
const steps = baseLine?.steps || [];
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '10px 20px 6px', borderBottom: `1px solid ${tokens.borderAccent}22` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="3" width="14" height="10" rx="2" stroke={tokens.accent} strokeWidth="1.2"/><path d="M4 7h8M4 9.5h5" stroke={tokens.accent} strokeWidth="1" strokeLinecap="round"/></svg>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: tokens.accent }}>生产成本测算模拟器</span>
|
||||||
|
{o && <span style={{ fontSize: 11, color: tokens.textMuted, padding: '1px 6px', borderRadius: 4, background: tokens.panelBgAlt }}>{o.customer} · {o.productCategory}</span>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setShowSim(false); setSimResult(null); }} style={{ padding: '2px 8px', borderRadius: 6, border: `1px solid ${tokens.border}`, background: 'transparent', color: tokens.textMuted, fontSize: 11, cursor: 'pointer' }}>✕ 关闭</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, marginBottom: 10 }}>
|
||||||
|
{steps.map(step => {
|
||||||
|
const eq = simEquip[step.name] || '自动线';
|
||||||
|
return (
|
||||||
|
<div key={step.name} style={{ padding: '8px 10px', borderRadius: 8, background: tokens.panelBgAlt, border: `1px solid ${tokens.border}` }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: tokens.text, marginBottom: 5 }}>{step.name}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{['自动线', '半自动线', '混合设备'].map(eqName => (
|
||||||
|
<button key={eqName} onClick={() => setSimEquip(p => ({ ...p, [step.name]: eqName }))} style={{
|
||||||
|
flex: 1, padding: '3px 2px', borderRadius: 5, fontSize: 10, cursor: 'pointer', transition: 'all 0.12s',
|
||||||
|
background: eq === eqName ? tokens.accentBg : 'transparent',
|
||||||
|
border: `1px solid ${eq === eqName ? tokens.accent : tokens.border}`,
|
||||||
|
color: eq === eqName ? tokens.accent : tokens.textMuted,
|
||||||
|
fontWeight: eq === eqName ? 700 : 400,
|
||||||
|
}}>{eqName}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<button onClick={handleGenerateSim} disabled={!o || isTyping || (simResult != null && !simResult.done)} style={{
|
||||||
|
padding: '7px 20px', borderRadius: 18, border: 'none', fontSize: 12, fontWeight: 700, cursor: o && !isTyping ? 'pointer' : 'not-allowed',
|
||||||
|
background: o && !isTyping ? `linear-gradient(135deg, ${tokens.accent}, ${tokens.accentGlow})` : tokens.panelBgAlt,
|
||||||
|
color: tokens.textInverse, boxShadow: o && !isTyping ? '0 0 14px rgba(0,170,255,0.3)' : 'none',
|
||||||
|
}}>{simResult && !simResult.done ? '⏳ 测算中…' : '🔧 生成测算结果'}</button>
|
||||||
|
<span style={{ fontSize: 11, color: tokens.textMuted }}>
|
||||||
|
{steps.map(s => `${s.name}: ${simEquip[s.name] || '自动线'}`).join(' → ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{simResult && (
|
||||||
|
<div style={{ marginTop: 8, padding: '8px 12px', borderRadius: 8, background: tokens.centerBg, border: `1px solid ${tokens.border}`, fontSize: 13, lineHeight: 1.65, whiteSpace: 'pre-line', color: tokens.text, maxHeight: 130, overflowY: 'auto' }}>
|
||||||
|
{simResult.thinking && (
|
||||||
|
<span style={{ fontStyle: 'italic', color: tokens.textMuted, display: 'block', marginBottom: simResult.thinkingDone && simResult.content ? 4 : 0 }}>
|
||||||
|
{!simResult.thinkingDone && <span style={{ display: 'inline-block', width: 5, height: 5, borderRadius: '50%', background: tokens.textMuted, marginRight: 4, verticalAlign: 'middle', animation: 'space-pulse 0.8s ease-in-out infinite' }} />}
|
||||||
|
{simResult.thinking}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{simResult.thinkingDone && simResult.content && <span>{simResult.content}{!simResult.done && <span style={{ display: 'inline-block', width: 2, height: '1em', background: tokens.accent, marginLeft: 2, verticalAlign: 'text-bottom', animation: 'space-pulse 0.6s ease-in-out infinite' }} />}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* 对话区 */}
|
||||||
|
<div ref={scrollRef} className="space-scroll" style={{ height: chatHeight, overflowY: 'auto', padding: '8px 24px 4px', transition: 'height 0.25s ease' }}>
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} style={{ display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start', marginBottom: 6 }}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<span style={{ width: 28, height: 28, borderRadius: '50%', background: tokens.panelBgAlt, border: `1px solid ${tokens.borderAccent}`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginRight: 7, marginTop: 2 }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="5" width="3" height="6" rx="1" fill={tokens.accent} opacity="0.8"/><rect x="6.5" y="3" width="3" height="10" rx="1" fill={tokens.accent}/><rect x="11" y="6" width="3" height="4" rx="1" fill={tokens.accent} opacity="0.8"/></svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div style={{ maxWidth: '82%' }}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<>
|
||||||
|
{msg.thinking && (
|
||||||
|
<div style={{ padding: '4px 10px', borderRadius: '9px 9px 9px 2px', background: tokens.panelBgAlt, border: `1px solid ${tokens.border}`, marginBottom: msg.thinkingDone && msg.content ? 5 : 0 }}>
|
||||||
|
<span style={{ fontStyle: 'italic', color: tokens.textMuted, fontSize: 14, lineHeight: 1.65, whiteSpace: 'pre-line', display: 'block' }}>
|
||||||
|
{!msg.thinkingDone && <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: tokens.textMuted, marginRight: 5, verticalAlign: 'middle', animation: 'space-pulse 0.8s ease-in-out infinite' }} />}
|
||||||
|
{msg.thinking}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg.thinkingDone && msg.content && (
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '2px 12px 12px 12px', background: tokens.centerBg, border: `1px solid ${tokens.border}`, color: tokens.text, fontSize: 15, lineHeight: 1.65, whiteSpace: 'pre-line' }}>
|
||||||
|
{msg.content}
|
||||||
|
{!msg.done && <span style={{ display: 'inline-block', width: 2, height: '1em', background: tokens.accent, marginLeft: 2, verticalAlign: 'text-bottom', animation: 'space-pulse 0.6s ease-in-out infinite' }} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '12px 12px 2px 12px', background: tokens.accentBg, border: `1px solid ${tokens.accent}33`, color: tokens.text, fontSize: 15, lineHeight: 1.65, whiteSpace: 'pre-line' }}>{msg.content}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 12, marginTop: 2, textAlign: msg.role === 'user' ? 'right' : 'left' }}>{msg.time}</div>
|
||||||
|
</div>
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<span style={{ width: 26, height: 26, borderRadius: '50%', background: tokens.accentBg, border: `1px solid ${tokens.accent}55`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginLeft: 7, marginTop: 2 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="5" r="3" stroke={tokens.accent} strokeWidth="1.2"/><path d="M 2,12 C 2,9.5 4.5,8 7,8 C 9.5,8 12,9.5 12,12" stroke={tokens.accent} strokeWidth="1.2" strokeLinecap="round"/></svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isTyping && messages[messages.length - 1]?.role !== 'ai' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||||
|
<span style={{ width: 28, height: 28, borderRadius: '50%', background: tokens.panelBgAlt, border: `1px solid ${tokens.borderAccent}`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="5" width="3" height="6" rx="1" fill={tokens.accent} opacity="0.8"/><rect x="6.5" y="3" width="3" height="10" rx="1" fill={tokens.accent}/><rect x="11" y="6" width="3" height="4" rx="1" fill={tokens.accent} opacity="0.8"/></svg>
|
||||||
|
</span>
|
||||||
|
<div style={{ padding: '6px 12px', borderRadius: '12px 12px 12px 2px', background: tokens.centerBg, border: `1px solid ${tokens.border}`, display: 'flex', gap: 5, alignItems: 'center' }}>
|
||||||
|
{[0, 1, 2].map(i => <span key={i} style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: tokens.accent, animation: `space-pulse 1s ${i * 0.2}s ease-in-out infinite` }} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<div style={{ padding: '4px 24px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 7, flexWrap: 'wrap' }}>
|
||||||
|
{QUICK_CMDS.map(cmd => (
|
||||||
|
<button key={cmd} onClick={() => sendMessage(cmd)} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 18, border: `1px solid ${tokens.borderAccent}`, background: tokens.accentBg, color: tokens.textSub, fontSize: 12, cursor: 'pointer', transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = tokens.tableRowHover; e.currentTarget.style.borderColor = tokens.accent; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = tokens.accentBg; e.currentTarget.style.borderColor = tokens.borderAccent; }}
|
||||||
|
>{cmd}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(input); } }}
|
||||||
|
placeholder={selectedOrder ? `询问关于 ${selectedOrder.customer} 订单的成本问题…` : '输入问题,如:为什么这个订单亏损?如何降低面料成本?'}
|
||||||
|
style={{ flex: 1, padding: '9px 16px', borderRadius: 22, background: tokens.panelBg, border: `1px solid ${tokens.borderAccent}`, color: tokens.text, fontSize: 13, outline: 'none', transition: 'border-color 0.15s ease' }}
|
||||||
|
onFocus={e => (e.target.style.borderColor = tokens.accent)}
|
||||||
|
onBlur={e => (e.target.style.borderColor = tokens.borderAccent)}
|
||||||
|
/>
|
||||||
|
<button onClick={() => sendMessage(input)} disabled={!input.trim() || isTyping} style={{
|
||||||
|
width: 40, height: 40, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: input.trim() && !isTyping ? `linear-gradient(135deg, ${tokens.accent}, ${tokens.accentGlow})` : tokens.panelBgAlt,
|
||||||
|
border: `1px solid ${tokens.borderAccent}`, color: input.trim() && !isTyping ? tokens.textInverse : tokens.textMuted,
|
||||||
|
fontSize: 17, cursor: input.trim() && !isTyping ? 'pointer' : 'not-allowed',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: input.trim() && !isTyping ? '0 0 12px rgba(0,170,255,0.4)' : 'none',
|
||||||
|
}}>
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none"><path d="M 2,15 L 15,8.5 L 2,2 L 2,7 L 10,8.5 L 2,10 Z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静态汇总文本
|
||||||
|
const ORDERS_SUMMARY = `客户 | 品类 | 净利率(领用)
|
||||||
|
Hakuba JP | 无缝背景纸 | +25.9% ✅
|
||||||
|
百联传媒集团 | 直播背景墙 | +16.7% ✅
|
||||||
|
Studio Gear EU | 便携背景架 | +8.3% ✅
|
||||||
|
PhotoPro Inc. | 影棚背景布 | +9.7% ✅
|
||||||
|
ATB | 摄影幕布 | +7.0% ⚠️
|
||||||
|
Amazon US | 摄影幕布套装| +1.9% ⚠️
|
||||||
|
Kenko Korea | 绿幕套装 | -1.5% 🚨
|
||||||
|
万博传媒科技 | 摄影幕布 | -7.2% 🚨`;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
import type { Order } from '../data/orders';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
order: Order | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMsg {
|
||||||
|
id: number;
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
thinking: string;
|
||||||
|
content: string;
|
||||||
|
thinkingDone: boolean;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_QS = ['哪些工序存在成本异常?', '热处理工艺偏差分析', '良率最低的环节是哪个?', '如何降低车加工成本?'];
|
||||||
|
|
||||||
|
function fmt(n: number) { return Math.round(n).toLocaleString('zh-CN'); }
|
||||||
|
|
||||||
|
export default function CenterAnomalyChat({ order }: Props) {
|
||||||
|
const { tokens } = useTheme();
|
||||||
|
const [messages, setMessages] = useState<ChatMsg[]>([{
|
||||||
|
id: 0, role: 'ai', thinking: '', thinkingDone: true, done: true,
|
||||||
|
content: '您好!我是成本异常分析智能体。\n\n我可以帮助您:\n• 识别各工序的成本异常工艺环节\n• 分析良率偏差与缺陷级联影响\n• 给出改善建议与预期收益\n\n请选择一个订单后提问,或直接点击下方的快捷问题。',
|
||||||
|
}]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const buildReply = useCallback((text: string): { thinking: string; content: string } => {
|
||||||
|
const o = order;
|
||||||
|
if (!o) return { thinking: '等待订单选择…', content: '请先从左侧选择一个订单,我将对该订单进行成本异常工艺分析。' };
|
||||||
|
const lines = o.productionLines || [];
|
||||||
|
const allSteps = lines.flatMap(l => l.steps.map(s => ({ ...s, line: l.name, lineEquip: l.equipment })));
|
||||||
|
|
||||||
|
if (text.includes('异常') || text.includes('问题') || text.includes('偏差')) {
|
||||||
|
const issues = allSteps.filter(s => s.hasIssue || s.defectRate > 0.02);
|
||||||
|
const thinking = `扫描订单 ${o.orderNo} 全部工序数据…\n检测各工序不良率与成本偏差…\n识别异常工艺信号…\n交叉验证产线设备状态…\n生成异常分析报告…`;
|
||||||
|
if (issues.length === 0) return { thinking, content: `📊 ${o.customer}(${o.orderNo})工艺异常分析\n\n✅ 全工序状态良好,未检测到显著异常信号。\n\n各工序不良率均在2%以内,成本偏差处于可控范围。\n建议维持当前工艺管控水平,定期复校工艺参数。` };
|
||||||
|
const analysis = issues.map(s => {
|
||||||
|
const level = s.hasIssue ? '🔴 高风险' : '🟡 关注';
|
||||||
|
return ` ${s.line} · ${s.name}:不良率 ${(s.defectRate * 100).toFixed(1)}% | 成本 ¥${fmt(s.cost)} | ${level}${s.issueDesc ? `\n → ${s.issueDesc}` : ''}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
return { thinking, content: `📊 ${o.customer}(${o.orderNo})工艺异常分析\n\n━━ 异常工序识别 ━━\n共发现 ${issues.length} 个异常信号:\n\n${analysis}\n\n━━ 改善建议 ━━\n${issues.filter(s => s.hasIssue).map(s => `• ${s.line}「${s.name}」:${s.issueDesc}\n → 建议:安排设备点检与工艺参数校准,预期良率提升 ${((0.03 - s.defectRate) * 100).toFixed(1)}%`).join('\n')}\n${issues.filter(s => !s.hasIssue && s.defectRate > 0.02).map(s => `• ${s.line}「${s.name}」:不良率偏高\n → 建议:加强过程巡检,实施SPC统计过程控制`).join('\n')}\n\n💡 建议对高风险工序建立「异常快速响应机制」,48h内完成根因分析与改善验证。` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('良率') || text.includes('最低') || text.includes('环节')) {
|
||||||
|
const worstSteps = [...allSteps].sort((a, b) => b.defectRate - a.defectRate).slice(0, 3);
|
||||||
|
const thinking = `计算各工序良率排名…\n分析不良率级联影响…\n评估改善优先级…`;
|
||||||
|
return { thinking, content: `📊 ${o.customer}(${o.orderNo})良率分析\n\n━━ 良率最低环节 TOP 3 ━━\n${worstSteps.map((s, i) => `${i + 1}. ${s.line} · ${s.name}:不良率 ${(s.defectRate * 100).toFixed(1)}%\n 成本 ¥${fmt(s.cost)} | 耗时 ${s.duration}h${s.issueDesc ? `\n ⚠️ ${s.issueDesc}` : ''}`).join('\n\n')}\n\n━━ 级联影响分析 ━━\n• 上游工序每降低1%不良率,下游可减少约 ¥${fmt(worstSteps[0]?.cost * 0.01 || 0)} 返工成本\n• 综合良率提升1%,预计每批次节省 ¥${fmt(worstSteps.reduce((s, st) => s + st.cost * st.defectRate, 0) * 100)}\n\n💡 建议优先改善「${worstSteps[0]?.name}」工序,ROI最高。` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes('降低') || text.includes('降本') || text.includes('改善') || text.includes('成本')) {
|
||||||
|
const topCost = [...allSteps].sort((a, b) => b.cost - a.cost)[0];
|
||||||
|
const thinking = `分析各工序成本贡献度…\n识别降本改善切入点…\n生成降本路线图…`;
|
||||||
|
return { thinking, content: `📊 ${o.customer}(${o.orderNo})降本分析\n\n━━ 成本 TOP 工序 ━━\n成本最高:${topCost?.line} · ${topCost?.name}(¥${fmt(topCost?.cost || 0)})\n\n━━ 降本建议 ━━\n• 对「${topCost?.name}」工序:优化工装夹具参数,减少刀具/砂轮损耗\n• 推行预防性维护,降低非计划停机时间\n• 对高不良率工序实施SPC控制,减少返工浪费\n• 评估半自动线升级ROI,对比自动线效率提升\n\n💡 综合良率每提升1%,预计年化节省约 ¥${fmt((o.rows.find(r => r.type === '管理成本')?.totalCost ?? 0) * 0.01)}。` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
thinking: '解析问题…\n检索相关工艺数据…\n生成分析回答…',
|
||||||
|
content: `${o ? `当前订单:${o.customer}(${o.orderNo})
|
||||||
|
|
||||||
|
` : ''}您的问题:「${text}」
|
||||||
|
|
||||||
|
建议尝试以下提问:
|
||||||
|
• "哪些工序存在成本异常?" — 扫描异常工艺信号
|
||||||
|
• "良率最低的环节是哪个?" — 良率排名与级联分析
|
||||||
|
• "如何降低车加工成本?" — 降本建议与ROI评估`,
|
||||||
|
};
|
||||||
|
}, [order]);
|
||||||
|
|
||||||
|
const send = useCallback((text: string) => {
|
||||||
|
if (!text.trim() || isTyping) return;
|
||||||
|
const userMsg: ChatMsg = { id: Date.now(), role: 'user', thinking: '', content: text, thinkingDone: true, done: true };
|
||||||
|
setMessages(prev => [...prev, userMsg]);
|
||||||
|
setInput('');
|
||||||
|
setIsTyping(true);
|
||||||
|
|
||||||
|
const aiId = Date.now() + 1;
|
||||||
|
setMessages(prev => [...prev, { id: aiId, role: 'ai', thinking: '', content: '', thinkingDone: false, done: false }]);
|
||||||
|
|
||||||
|
const { thinking, content } = buildReply(text);
|
||||||
|
let ti = 0;
|
||||||
|
const thinkTick = () => {
|
||||||
|
ti += Math.floor(Math.random() * 3) + 1;
|
||||||
|
setMessages(prev => prev.map(m => m.id === aiId ? { ...m, thinking: thinking.slice(0, Math.min(ti, thinking.length)) } : m));
|
||||||
|
if (ti < thinking.length) { timerRef.current = setTimeout(thinkTick, 45); }
|
||||||
|
else {
|
||||||
|
setMessages(prev => prev.map(m => m.id === aiId ? { ...m, thinkingDone: true } : m));
|
||||||
|
setTimeout(() => {
|
||||||
|
let ci = 0;
|
||||||
|
const contentTick = () => {
|
||||||
|
ci += Math.floor(Math.random() * 3) + 1;
|
||||||
|
setMessages(prev => prev.map(m => m.id === aiId ? { ...m, content: content.slice(0, Math.min(ci, content.length)) } : m));
|
||||||
|
if (ci < content.length) { timerRef.current = setTimeout(contentTick, 25); }
|
||||||
|
else { setMessages(prev => prev.map(m => m.id === aiId ? { ...m, done: true } : m)); setIsTyping(false); }
|
||||||
|
};
|
||||||
|
timerRef.current = setTimeout(contentTick, 25);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timerRef.current = setTimeout(thinkTick, 45);
|
||||||
|
}, [isTyping, buildReply]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
{/* 对话区 */}
|
||||||
|
<div ref={scrollRef} style={{ flex: 1, overflowY: 'auto', padding: '12px 20px' }}>
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} style={{ display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start', marginBottom: 10 }}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<span style={{ width: 28, height: 28, borderRadius: '50%', background: tokens.panelBgAlt, border: `1px solid ${tokens.borderAccent}44`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginRight: 8, marginTop: 2, fontSize: 14 }}>⚠️</span>
|
||||||
|
)}
|
||||||
|
<div style={{ maxWidth: '80%' }}>
|
||||||
|
{msg.role === 'ai' && msg.thinking && (
|
||||||
|
<div style={{ padding: '6px 10px', borderRadius: '8px 8px 8px 2px', background: tokens.panelBgAlt, border: `1px solid ${tokens.border}`, marginBottom: msg.thinkingDone && msg.content ? 4 : 0 }}>
|
||||||
|
<span style={{ fontStyle: 'italic', color: tokens.textMuted, fontSize: 12, lineHeight: 1.6, whiteSpace: 'pre-line', display: 'block' }}>
|
||||||
|
{!msg.thinkingDone && <span style={{ display: 'inline-block', width: 5, height: 5, borderRadius: '50%', background: tokens.textMuted, marginRight: 4, verticalAlign: 'middle', animation: 'space-pulse 0.8s ease-in-out infinite' }} />}
|
||||||
|
{msg.thinking}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg.role === 'ai' && msg.thinkingDone && msg.content && (
|
||||||
|
<div style={{ padding: '8px 12px', borderRadius: '2px 10px 10px 10px', background: tokens.centerBg, border: `1px solid ${tokens.border}`, color: tokens.text, fontSize: 13, lineHeight: 1.7, whiteSpace: 'pre-line' }}>
|
||||||
|
{msg.content}
|
||||||
|
{!msg.done && <span style={{ display: 'inline-block', width: 2, height: '1em', background: tokens.accent, marginLeft: 2, verticalAlign: 'text-bottom', animation: 'space-pulse 0.6s ease-in-out infinite' }} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div style={{ padding: '8px 12px', borderRadius: '10px 10px 2px 10px', background: tokens.accentBg, border: `1px solid ${tokens.accent}33`, color: tokens.text, fontSize: 13, lineHeight: 1.7, whiteSpace: 'pre-line' }}>{msg.content}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷问题 */}
|
||||||
|
<div style={{ padding: '4px 20px 6px', display: 'flex', gap: 6, flexWrap: 'wrap', flexShrink: 0 }}>
|
||||||
|
{QUICK_QS.map(q => (
|
||||||
|
<button key={q} onClick={() => send(q)} disabled={isTyping} style={{
|
||||||
|
padding: '4px 10px', borderRadius: 16, border: `1px solid ${tokens.borderAccent}44`,
|
||||||
|
background: tokens.accentBg, color: tokens.textSub, fontSize: 11, cursor: isTyping ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = tokens.accent; e.currentTarget.style.color = tokens.accent; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = `${tokens.borderAccent}44`; e.currentTarget.style.color = tokens.textSub; }}
|
||||||
|
>{q}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<div style={{ padding: '6px 20px 12px', display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(input); } }}
|
||||||
|
placeholder={order ? `输入成本异常相关问题…` : '请先选择订单…'}
|
||||||
|
style={{ flex: 1, padding: '8px 14px', borderRadius: 20, background: tokens.panelBg, border: `1px solid ${tokens.borderAccent}44`, color: tokens.text, fontSize: 12, outline: 'none' }}
|
||||||
|
onFocus={e => (e.target.style.borderColor = tokens.accent)}
|
||||||
|
onBlur={e => (e.target.style.borderColor = `${tokens.borderAccent}44`)}
|
||||||
|
/>
|
||||||
|
<button onClick={() => send(input)} disabled={!input.trim() || isTyping} style={{
|
||||||
|
width: 36, height: 36, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: input.trim() && !isTyping ? `linear-gradient(135deg, ${tokens.accent}, ${tokens.accentGlow})` : tokens.panelBgAlt,
|
||||||
|
border: `1px solid ${tokens.borderAccent}`, color: input.trim() && !isTyping ? tokens.textInverse : tokens.textMuted,
|
||||||
|
fontSize: 15, cursor: input.trim() && !isTyping ? 'pointer' : 'not-allowed',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 17 17" fill="none"><path d="M2,15 L15,8.5 L2,2 L2,7 L10,8.5 L2,10 Z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
import { useState, type DragEvent } from 'react';
|
||||||
|
import type { Order, QuoteCostItem } from '../data/orders';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
order: Order | null;
|
||||||
|
onDrop?: (event: DragEvent<HTMLDivElement>) => void;
|
||||||
|
isProcessing?: boolean;
|
||||||
|
processingOrder?: Order | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAT_CFG = [
|
||||||
|
{ key: '原材料', color: '#00b4d8', icon: '🔩' },
|
||||||
|
{ key: '直接人工', color: '#f59e0b', icon: '👷' },
|
||||||
|
{ key: '其他费用', color: '#8b5cf6', icon: '⚙️' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 物料规格映射 ──────────────────────────────────────────────────────
|
||||||
|
interface ProcessStep {
|
||||||
|
name: string;
|
||||||
|
impact: 'high' | 'medium' | 'low';
|
||||||
|
desc: string;
|
||||||
|
costDriver: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatInfo {
|
||||||
|
code: string;
|
||||||
|
spec: string;
|
||||||
|
unit: string;
|
||||||
|
qtyPerUnit: number;
|
||||||
|
supplier: string;
|
||||||
|
leadDays: number;
|
||||||
|
lossRate: number;
|
||||||
|
lossSource: string;
|
||||||
|
processes: ProcessStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAT_MAP: Record<string, MatInfo> = {
|
||||||
|
'外圈': {
|
||||||
|
code: 'MT-OR-001', spec: 'GCr15 轴承钢套圈 Φ52×Φ25×15mm', unit: '件', qtyPerUnit: 1, supplier: '宝钢特钢', leadDays: 14, lossRate: 0.012, lossSource: '车/磨工序历史不良率',
|
||||||
|
processes: [
|
||||||
|
{ name: '锻造毛坯', impact: 'medium', desc: '套圈毛坯成型,材料利用率约 68%', costDriver: '棒材规格与下料损耗' },
|
||||||
|
{ name: '车削加工', impact: 'medium', desc: '内外径及沟道粗车,留磨削余量 0.25mm', costDriver: '切削参数与刀具寿命' },
|
||||||
|
{ name: '热处理', impact: 'high', desc: '淬火+低温回火,硬度 HRC 61-65', costDriver: '能耗、淬火介质及变形控制' },
|
||||||
|
{ name: '磨削精加工', impact: 'high', desc: '沟道与外径精磨,Ra 0.4μm', costDriver: '砂轮消耗、磨削工时、废品率' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'内圈': {
|
||||||
|
code: 'MT-IR-002', spec: 'GCr15 轴承钢套圈 Φ25×Φ15×15mm', unit: '件', qtyPerUnit: 1, supplier: '宝钢特钢', leadDays: 14, lossRate: 0.012, lossSource: '车/磨工序历史不良率',
|
||||||
|
processes: [
|
||||||
|
{ name: '锻造毛坯', impact: 'medium', desc: '内圈毛坯成型,材料利用率约 65%', costDriver: '棒材规格与下料损耗' },
|
||||||
|
{ name: '车削加工', impact: 'medium', desc: '内径、外径及滚道粗车', costDriver: '切削参数与刀具寿命' },
|
||||||
|
{ name: '热处理', impact: 'high', desc: '淬火+低温回火,控制椭圆变形', costDriver: '能耗、夹具及变形返工' },
|
||||||
|
{ name: '磨削精加工', impact: 'high', desc: '滚道与内径精磨,尺寸精度 IT5', costDriver: '磨削工时、砂轮、测量频次' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'滚针': {
|
||||||
|
code: 'MT-RN-003', spec: 'GCr10 精密滚针 Φ3×15.8mm', unit: '粒', qtyPerUnit: 14, supplier: '人本集团', leadDays: 7, lossRate: 0.018, lossSource: '装配工序历史损耗率',
|
||||||
|
processes: [
|
||||||
|
{ name: '冷镦成型', impact: 'medium', desc: '钢丝冷镦成圆柱坯', costDriver: '材料利用率与模具寿命' },
|
||||||
|
{ name: '磨削', impact: 'high', desc: '无心磨保证直径公差 ±0.003mm', costDriver: '磨削工时、砂轮消耗' },
|
||||||
|
{ name: '分选清洗', impact: 'low', desc: '长度与直径分组、防锈清洗', costDriver: '分选设备折旧与辅料' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'保持器': {
|
||||||
|
code: 'MT-CG-004', spec: 'PA66 尼龙保持架组件', unit: '套', qtyPerUnit: 1, supplier: '恩格尔', leadDays: 21, lossRate: 0.008, lossSource: '注塑/装配历史不良率',
|
||||||
|
processes: [
|
||||||
|
{ name: '注塑成型', impact: 'high', desc: 'PA66+玻纤注塑,一模多腔', costDriver: '原料价格、模具寿命、周期时间' },
|
||||||
|
{ name: '去毛刺', impact: 'low', desc: '人工/振动去毛刺', costDriver: '人工工时' },
|
||||||
|
{ name: '尺寸检验', impact: 'medium', desc: ' pockets 间距与直径全检', costDriver: '检测工时与治具' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'挡圈': {
|
||||||
|
code: 'MT-SR-005', spec: '65Mn 弹簧钢挡圈 Φ48mm', unit: '件', qtyPerUnit: 2, supplier: '东明标准件', leadDays: 5, lossRate: 0.006, lossSource: '冲压工序历史不良率',
|
||||||
|
processes: [
|
||||||
|
{ name: '冲压落料', impact: 'high', desc: '带钢连续冲裁成型,材料利用率 75%', costDriver: '带钢价格、模具寿命、冲次' },
|
||||||
|
{ name: '热处理定型', impact: 'medium', desc: '淬火回火获得弹性', costDriver: '能耗与变形控制' },
|
||||||
|
{ name: '表面处理', impact: 'low', desc: '氧化或磷化防锈', costDriver: '表面处理药水与环保成本' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'密封': {
|
||||||
|
code: 'MT-SL-006', spec: 'NBR 橡胶密封圈 Φ52×Φ25×7mm', unit: '件', qtyPerUnit: 2, supplier: '科德宝', leadDays: 10, lossRate: 0.010, lossSource: '硫化/装配历史不良率',
|
||||||
|
processes: [
|
||||||
|
{ name: '混炼胶料', impact: 'medium', desc: 'NBR 混炼,硬度 70±5 Shore A', costDriver: '橡胶原料配方成本' },
|
||||||
|
{ name: '硫化成型', impact: 'high', desc: '模压硫化,控制飞边与气泡', costDriver: '模具、硫化周期、能耗' },
|
||||||
|
{ name: '修边检验', impact: 'low', desc: '冷冻修边+气密检测', costDriver: '人工与检测辅料' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'油塞': {
|
||||||
|
code: 'MT-OP-007', spec: 'M6×1 铜质注油塞', unit: '件', qtyPerUnit: 1, supplier: '万向钱潮', leadDays: 3, lossRate: 0.005, lossSource: '机加工历史不良率',
|
||||||
|
processes: [
|
||||||
|
{ name: '自动车削', impact: 'high', desc: '铜棒自动车成形,含螺纹', costDriver: '铜材价格、刀具、加工效率' },
|
||||||
|
{ name: '滚珠压装', impact: 'medium', desc: '钢珠压入形成单向阀', costDriver: '钢珠成本与压装合格率' },
|
||||||
|
{ name: '表面处理', impact: 'low', desc: '清洗钝化', costDriver: '清洗液与环保处理' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'人工': { code: 'LB-DL-001', spec: '直接加工工时', unit: '工时', qtyPerUnit: 0.12, supplier: '—', leadDays: 0, lossRate: 0, lossSource: '—', processes: [] },
|
||||||
|
'费用': { code: 'OH-MF-001', spec: '制造费用分摊', unit: '批', qtyPerUnit: 0, supplier: '—', leadDays: 0, lossRate: 0, lossSource: '—', processes: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── BOM 图片库(料号 → 图片 URL)────────────────────────────────────────
|
||||||
|
const BOM_IMAGE_LIB: Record<string, string> = {
|
||||||
|
'MT-OR-001': '/bom-images/MT-OR-001.png',
|
||||||
|
'MT-IR-002': '/bom-images/MT-IR-002.png',
|
||||||
|
'MT-RN-003': '/bom-images/MT-RN-003.png',
|
||||||
|
'MT-CG-004': '/bom-images/MT-CG-004.png',
|
||||||
|
'MT-SR-005': '/bom-images/MT-SR-005.png',
|
||||||
|
'MT-SL-006': '/bom-images/MT-SL-006.png',
|
||||||
|
'MT-OP-007': '/bom-images/MT-OP-007.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CenterBOMView({ order, onDrop, isProcessing, processingOrder }: Props) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set(CAT_CFG.map(c => c.key)));
|
||||||
|
const [preview, setPreview] = useState<{ code: string; name: string; url: string } | null>(null);
|
||||||
|
const [previewTab, setPreviewTab] = useState<'drawing' | 'process'>('drawing');
|
||||||
|
const displayOrder = order || processingOrder;
|
||||||
|
|
||||||
|
const toggleCat = (key: string) => {
|
||||||
|
setExpandedCats(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
setExpandedCats(prev => {
|
||||||
|
const allKeys = CAT_CFG.map(c => c.key);
|
||||||
|
const allExpanded = allKeys.every(k => prev.has(k));
|
||||||
|
return allExpanded ? new Set() : new Set(allKeys);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 空状态 / 拖拽区域 ────────────────────────────────────────────
|
||||||
|
if (isProcessing) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{ width: 56, height: 56, borderRadius: '50%', border: `3px solid ${tokens.accent}22`, borderTopColor: tokens.accent, animation: 'space-pulse 1s linear infinite' }} />
|
||||||
|
<p style={{ marginTop: 16, color: tokens.accent, fontSize: 15, fontWeight: 600 }}>正在拆解客户需求产品…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!displayOrder) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; }}
|
||||||
|
onDragEnter={e => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={e => { setDragOver(false); onDrop?.(e); }}
|
||||||
|
style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40,
|
||||||
|
background: dragOver ? tokens.accentBg : 'transparent', transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 80, height: 80, borderRadius: 20,
|
||||||
|
background: dragOver ? tokens.accent + '22' : tokens.accentBg,
|
||||||
|
border: `2px dashed ${dragOver ? tokens.accent : tokens.borderAccent + '44'}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 20, fontSize: 36,
|
||||||
|
transform: dragOver ? 'scale(1.1)' : 'scale(1)', transition: 'all 0.2s ease',
|
||||||
|
}}>🔍</div>
|
||||||
|
<h3 style={{ color: dragOver ? tokens.accent : tokens.text, fontSize: 18, fontWeight: 700, marginBottom: 8, transition: 'color 0.2s ease' }}>
|
||||||
|
{dragOver ? '释放鼠标,开始拆解' : '拆解客户需求产品'}
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: tokens.textMuted, fontSize: 13, textAlign: 'center', maxWidth: 400, lineHeight: 1.8 }}>
|
||||||
|
从左侧拖拽订单到此处,AI 将自动拆解该轴承订单的产品结构图,<br/>展示零部件的数量、规格及层级关系。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bd = displayOrder.costBreakdown || [];
|
||||||
|
const qty = displayOrder.rows[0]?.qty ?? 1;
|
||||||
|
const quoteItems = displayOrder.quoteCostItems || [];
|
||||||
|
const getQuote = (label: string): QuoteCostItem | undefined => quoteItems.find(q => q.name === label);
|
||||||
|
|
||||||
|
// 汇总数据
|
||||||
|
const totalItems = bd.reduce((s, c) => s + c.items.length, 0);
|
||||||
|
const allExpanded = CAT_CFG.every(c => expandedCats.has(c.key));
|
||||||
|
|
||||||
|
// ── 卡片公共样式 ────────────────────────────────────────────────────
|
||||||
|
const cardStyle = (color: string, isHover: boolean): React.CSSProperties => ({
|
||||||
|
padding: '10px 14px', borderRadius: 10,
|
||||||
|
background: isLight ? (isHover ? '#f0f6ff' : '#ffffff') : (isHover ? `${color}0d` : 'rgba(0,20,50,0.35)'),
|
||||||
|
border: `1px solid ${isLight ? 'rgba(30,60,130,0.12)' : `${color}30`}`,
|
||||||
|
boxShadow: isLight ? '0 1px 4px rgba(0,30,100,0.06)' : 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleBtn = (expanded: boolean, color: string) => (
|
||||||
|
<span style={{
|
||||||
|
width: 20, height: 20, borderRadius: 5,
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: color + '18', color,
|
||||||
|
fontSize: 13, fontWeight: 700, lineHeight: 1,
|
||||||
|
border: `1px solid ${color}40`,
|
||||||
|
}}>{expanded ? '−' : '+'}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
{/* ── 订单头部 ── */}
|
||||||
|
<div style={{ padding: '10px 20px', borderBottom: `1px solid ${tokens.border}`, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ width: 38, height: 38, borderRadius: 10, background: tokens.accentBg, border: `1px solid ${tokens.borderAccent}33`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, flexShrink: 0 }}>⚙️</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3 style={{ color: tokens.text, fontSize: 15, fontWeight: 700, margin: 0 }}>{displayOrder.customer} · 产品结构拆解</h3>
|
||||||
|
<p style={{ color: tokens.textMuted, fontSize: 11, margin: '2px 0 0' }}>{displayOrder.orderNo} · {displayOrder.productCategory} · {qty.toLocaleString()} 件 · 含 {totalItems} 种零部件</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 产品结构树 ── */}
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', padding: '12px 20px 20px' }}>
|
||||||
|
|
||||||
|
{/* ═══ Level 0:产品根节点 ═══ */}
|
||||||
|
<div style={{ ...cardStyle(tokens.accent, false), border: `2px solid ${tokens.accent}44`, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div style={{ width: 48, height: 48, borderRadius: 12, background: `linear-gradient(135deg, ${tokens.accent}22, ${tokens.accent}11)`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26, flexShrink: 0 }}>⚙️</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 800, color: tokens.text, marginBottom: 2 }}>{displayOrder.productCategory}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 12, color: tokens.textMuted }}>制造令号 <b style={{ color: tokens.accent, fontFamily: 'monospace' }}>{displayOrder.orderNo}</b></span>
|
||||||
|
<span style={{ fontSize: 12, color: tokens.textMuted }}>客户 <b style={{ color: tokens.text }}>{displayOrder.customer}</b></span>
|
||||||
|
<span style={{ fontSize: 12, color: tokens.textMuted }}>需求数量 <b style={{ color: tokens.text }}>{qty.toLocaleString()} 件</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleAll} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '5px 10px', borderRadius: 7,
|
||||||
|
border: `1px solid ${tokens.accent}50`,
|
||||||
|
background: tokens.accentBg,
|
||||||
|
color: tokens.accent, fontSize: 12, fontWeight: 600,
|
||||||
|
cursor: 'pointer', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{toggleBtn(allExpanded, tokens.accent)}
|
||||||
|
{allExpanded ? '收起全部' : '展开全部'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ 垂直连接线 ═══ */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', height: 24 }}>
|
||||||
|
<div style={{ width: 2, height: '100%', background: `linear-gradient(to bottom, ${tokens.accent}44, ${tokens.accent}22)` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ Level 1:成本分类 ═══ */}
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
{bd.map((cat, catIdx) => {
|
||||||
|
const catCfg = CAT_CFG[catIdx] || CAT_CFG[0];
|
||||||
|
const items = cat.items;
|
||||||
|
const isExpanded = expandedCats.has(catCfg.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={catCfg.key} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* 分类卡片 */}
|
||||||
|
<div style={{ ...cardStyle(catCfg.color, false), borderTop: `3px solid ${catCfg.color}`, marginBottom: isExpanded ? 4 : 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 16 }}>{catCfg.icon}</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: catCfg.color }}>{catCfg.key}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, padding: '1px 6px', borderRadius: 8,
|
||||||
|
background: catCfg.color + '18', color: catCfg.color, fontWeight: 600,
|
||||||
|
}}>{items.length} 项</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => toggleCat(catCfg.key)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '3px 8px', borderRadius: 6,
|
||||||
|
border: `1px solid ${catCfg.color}40`,
|
||||||
|
background: catCfg.color + '10',
|
||||||
|
color: catCfg.color, fontSize: 11, fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{toggleBtn(isExpanded, catCfg.color)}
|
||||||
|
{isExpanded ? '收起' : '展开'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ Level 2:零部件列表 ═══ */}
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
{/* 分类→零件连接线 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', height: 14 }}>
|
||||||
|
<div style={{ width: 2, height: '100%', background: `${catCfg.color}33` }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{items.map(item => {
|
||||||
|
const q = getQuote(item.label);
|
||||||
|
const mat = MAT_MAP[item.label];
|
||||||
|
const itemQty = q?.quantity ?? qty;
|
||||||
|
const demandQty = mat && mat.qtyPerUnit > 0 ? Math.ceil(qty * mat.qtyPerUnit) : itemQty;
|
||||||
|
const displayUnit = mat?.unit ?? '件';
|
||||||
|
const lossRate = mat?.lossRate ?? 0;
|
||||||
|
const lossQty = Math.max(1, Math.round(demandQty * lossRate));
|
||||||
|
const showLoss = lossRate > 0 && catCfg.key === '原材料';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.label} style={{
|
||||||
|
...cardStyle(catCfg.color, false),
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderLeft: `3px solid ${catCfg.color}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: tokens.text }}>{item.label}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: catCfg.color, fontFamily: 'monospace' }}>
|
||||||
|
{demandQty.toLocaleString()} {displayUnit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* 规格描述 */}
|
||||||
|
{mat && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 9, color: tokens.textMuted, lineHeight: 1.4 }} title={mat.spec}>
|
||||||
|
{mat.spec.length > 28 ? mat.spec.slice(0, 28) + '…' : mat.spec}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 9, color: tokens.textMuted }}>物料编码 {mat.code}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* AI 理论损耗数量 */}
|
||||||
|
{showLoss && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 5, padding: '4px 8px', borderRadius: 5,
|
||||||
|
background: isLight ? 'rgba(255,107,107,0.08)' : 'rgba(255,107,107,0.12)',
|
||||||
|
border: `1px solid ${tokens.danger}30`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6,
|
||||||
|
}} title={`依据:${mat?.lossSource}`}>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, color: tokens.danger }}>AI 理论损耗数量</span>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, color: tokens.danger, fontFamily: 'monospace' }}>
|
||||||
|
+{lossQty.toLocaleString()} {displayUnit}({(lossRate * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* BOM 图片库查询结果 */}
|
||||||
|
{mat && BOM_IMAGE_LIB[mat.code] && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 5, padding: '4px 8px', borderRadius: 5,
|
||||||
|
background: isLight ? 'rgba(0,212,255,0.08)' : 'rgba(0,212,255,0.12)',
|
||||||
|
border: `1px solid ${tokens.accent}35`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, color: tokens.accent }}>BOM 图片库查询结果</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 10, color: tokens.textMuted, fontFamily: 'monospace' }}>{mat.code}</span>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setPreview({ code: mat.code, name: item.label, url: BOM_IMAGE_LIB[mat.code] }); }}
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px', borderRadius: 4, fontSize: 10, fontWeight: 600,
|
||||||
|
background: tokens.accentBg, color: tokens.accent,
|
||||||
|
border: `1px solid ${tokens.accent}50`, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>查看</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ 底部汇总:非金额信息 ═══ */}
|
||||||
|
<div style={{ marginTop: 14, display: 'flex', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, padding: '10px 14px', borderRadius: 10,
|
||||||
|
background: isLight ? 'rgba(22,56,140,0.05)' : 'rgba(0,212,255,0.06)',
|
||||||
|
border: `1px solid ${tokens.accent}22`,
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, color: tokens.textMuted }}>零部件种类</span>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 700, color: tokens.accent, fontFamily: 'monospace' }}>{totalItems} 种</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, padding: '10px 14px', borderRadius: 10,
|
||||||
|
background: isLight ? 'rgba(4,120,87,0.05)' : 'rgba(0,229,153,0.06)',
|
||||||
|
border: `1px solid ${tokens.success}22`,
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, color: tokens.textMuted }}>需求数量</span>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 700, color: tokens.success, fontFamily: 'monospace' }}>{qty.toLocaleString()} 件</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, padding: '10px 14px', borderRadius: 10,
|
||||||
|
background: isLight ? 'rgba(180,83,9,0.05)' : 'rgba(255,179,64,0.06)',
|
||||||
|
border: `1px solid ${tokens.warning}22`,
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, color: tokens.textMuted }}>成本分类</span>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 700, color: tokens.warning, fontFamily: 'monospace' }}>{bd.length} 类</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ BOM 图片放大预览层 ═══ */}
|
||||||
|
{preview && (() => {
|
||||||
|
const matInfo = Object.values(MAT_MAP).find(m => m.code === preview.code);
|
||||||
|
const processes = matInfo?.processes ?? [];
|
||||||
|
const highImpactCount = processes.filter(p => p.impact === 'high').length;
|
||||||
|
const impactColor = { high: tokens.danger, medium: tokens.warning, low: tokens.success };
|
||||||
|
const impactLabel = { high: '高成本影响', medium: '中成本影响', low: '低成本影响' };
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => setPreview(null)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 9999,
|
||||||
|
background: 'rgba(2,6,23,0.88)',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 24, cursor: 'zoom-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
maxWidth: 720, width: '100%',
|
||||||
|
background: isLight ? '#fff' : tokens.panelBg,
|
||||||
|
borderRadius: 16,
|
||||||
|
border: `1px solid ${tokens.accent}30`,
|
||||||
|
boxShadow: '0 24px 80px rgba(0,0,0,0.45)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 18px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
borderBottom: `1px solid ${tokens.border}`,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: tokens.text }}>{preview.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: tokens.textMuted, marginTop: 2, fontFamily: 'monospace' }}>料号:{preview.code}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setPreview(null)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 600,
|
||||||
|
background: tokens.accentBg, color: tokens.accent,
|
||||||
|
border: `1px solid ${tokens.accent}50`, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>还原</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 切换 */}
|
||||||
|
<div style={{ display: 'flex', borderBottom: `1px solid ${tokens.border}` }}>
|
||||||
|
{(['drawing', 'process'] as const).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setPreviewTab(tab)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '10px 0', fontSize: 13, fontWeight: 600,
|
||||||
|
background: previewTab === tab ? (isLight ? 'rgba(0,212,255,0.08)' : 'rgba(0,212,255,0.12)') : 'transparent',
|
||||||
|
color: previewTab === tab ? tokens.accent : tokens.textMuted,
|
||||||
|
border: 'none', borderBottom: `2px solid ${previewTab === tab ? tokens.accent : 'transparent'}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>{tab === 'drawing' ? 'CAD 图纸' : 'AI 关键工艺指引'}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CAD 图纸内容 */}
|
||||||
|
{previewTab === 'drawing' && (
|
||||||
|
<div style={{ padding: 18, display: 'flex', justifyContent: 'center', background: '#0b1120' }}>
|
||||||
|
<img
|
||||||
|
src={preview.url}
|
||||||
|
alt={preview.name}
|
||||||
|
style={{ maxWidth: '100%', borderRadius: 10, border: `1px solid ${tokens.accent}30` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI 关键工艺指引内容 */}
|
||||||
|
{previewTab === 'process' && (
|
||||||
|
<div style={{ padding: 18, maxHeight: 420, overflowY: 'auto' }}>
|
||||||
|
<div style={{
|
||||||
|
marginBottom: 14, padding: '10px 14px', borderRadius: 8,
|
||||||
|
background: isLight ? 'rgba(0,212,255,0.06)' : 'rgba(0,212,255,0.10)',
|
||||||
|
border: `1px solid ${tokens.accent}25`,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: tokens.accent, marginBottom: 4 }}>AI 工艺成本洞察</div>
|
||||||
|
<div style={{ fontSize: 12, color: tokens.textSub, lineHeight: 1.5 }}>
|
||||||
|
该物料共 {processes.length} 道关键工序,其中 <span style={{ color: tokens.danger, fontWeight: 700 }}>{highImpactCount} 道</span> 对成本影响较大。
|
||||||
|
重点关注红色标注工序,其工艺参数波动会显著影响材料损耗、加工工时或废品率。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{processes.map((p, idx) => (
|
||||||
|
<div key={p.name} style={{
|
||||||
|
padding: '12px 14px', borderRadius: 10,
|
||||||
|
background: isLight ? 'rgba(15,23,42,0.03)' : 'rgba(255,255,255,0.03)',
|
||||||
|
border: `1px solid ${impactColor[p.impact]}30`,
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 22, height: 22, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: impactColor[p.impact] + '20', color: impactColor[p.impact], fontSize: 11, fontWeight: 700,
|
||||||
|
}}>{idx + 1}</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: tokens.text }}>{p.name}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
padding: '3px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700,
|
||||||
|
background: impactColor[p.impact] + '18', color: impactColor[p.impact],
|
||||||
|
}}>{impactLabel[p.impact]}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: tokens.textSub, lineHeight: 1.5, marginBottom: 4 }}>{p.desc}</div>
|
||||||
|
<div style={{ fontSize: 11, color: tokens.textMuted }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>成本驱动:</span>{p.costDriver}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 14, fontSize: 13, color: 'rgba(255,255,255,0.6)' }}>点击图片任意区域可还原</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import type { Order } from '../data/orders';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface Props { order: Order | null; }
|
||||||
|
|
||||||
|
function fmt(n: number) { return Math.round(n).toLocaleString('zh-CN'); }
|
||||||
|
function pct(n: number) { return `${(n * 100).toFixed(1)}%`; }
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
'读取理论成本与生产数据…',
|
||||||
|
'计算各零件标准成本基准…',
|
||||||
|
'映射产线工序不良率损耗…',
|
||||||
|
'计算三种产线的损耗成本…',
|
||||||
|
'生成成本差异分析报告…',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LINE_COLORS = ['#00cc88', '#ff8844', '#4499ff'];
|
||||||
|
const LINE_NAMES = ['A线(自动线)', 'B线(半自动线)', 'C线(混合)'];
|
||||||
|
|
||||||
|
interface PartRow {
|
||||||
|
name: string;
|
||||||
|
stdCost: number;
|
||||||
|
lineCosts: [number, number, number];
|
||||||
|
avgCost: number;
|
||||||
|
variance: number;
|
||||||
|
variancePct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CenterCostCompare({ order }: Props) {
|
||||||
|
const { tokens, theme } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!order) { setDone(false); setGenerating(false); return; }
|
||||||
|
setGenerating(true); setStep(0); setProgress(0); setDone(false);
|
||||||
|
let i = 0;
|
||||||
|
const tick = () => {
|
||||||
|
i++; setStep(i);
|
||||||
|
setProgress(Math.min(100, Math.round((i / STEPS.length) * 100)));
|
||||||
|
if (i < STEPS.length) {
|
||||||
|
timerRef.current = setTimeout(tick, 220 + Math.random() * 80);
|
||||||
|
} else {
|
||||||
|
timerRef.current = setTimeout(() => { setGenerating(false); setDone(true); }, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timerRef.current = setTimeout(tick, 200);
|
||||||
|
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||||
|
}, [order]);
|
||||||
|
|
||||||
|
// ── 空状态 ──
|
||||||
|
if (!order) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||||
|
<div style={{ width: 80, height: 80, borderRadius: 20, background: tokens.accentBg, border: `2px dashed ${tokens.borderAccent}44`, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 20, fontSize: 36 }}>📊</div>
|
||||||
|
<h3 style={{ color: tokens.text, fontSize: 18, fontWeight: 700, marginBottom: 8 }}>成本对比分析</h3>
|
||||||
|
<p style={{ color: tokens.textMuted, fontSize: 13, textAlign: 'center', maxWidth: 400, lineHeight: 1.8 }}>
|
||||||
|
请先从左侧选择一个订单,<br />系统将自动对比理论成本与各产线损耗成本,<br />定位成本差异最大的零件。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 动画 ──
|
||||||
|
if (generating) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||||
|
<div style={{ width: 52, height: 52, borderRadius: '50%', border: `3px solid ${tokens.accent}22`, borderTopColor: tokens.accent, animation: 'space-pulse 1s linear infinite', marginBottom: 16 }} />
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: tokens.accent, marginBottom: 6 }}>📊 成本对比分析中</div>
|
||||||
|
<div style={{ fontSize: 11, color: tokens.textMuted, marginBottom: 16 }}>正在对比理论成本与各产线损耗差异…</div>
|
||||||
|
<div style={{ width: 260, height: 4, borderRadius: 2, background: tokens.border, overflow: 'hidden', marginBottom: 14 }}>
|
||||||
|
<div style={{ width: `${progress}%`, height: '100%', borderRadius: 2, background: tokens.accent, transition: 'width 0.3s ease' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 250 }}>
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
padding: '3px 0', fontSize: 11,
|
||||||
|
color: i < step ? tokens.success : i === step ? tokens.accent : `${tokens.textMuted}44`,
|
||||||
|
fontWeight: i === step ? 600 : 400, display: 'flex', gap: 6,
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 14 }}>{i < step ? '✓' : i === step ? '▸' : '○'}</span>{s}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底 spinner(防止 return null 黑屏)
|
||||||
|
if (!done) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: '50%', border: `3px solid ${tokens.accent}22`, borderTopColor: tokens.accent, animation: 'space-pulse 1s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 数据准备 ──
|
||||||
|
const bd = order.costBreakdown || [];
|
||||||
|
const lines = order.productionLines || [];
|
||||||
|
|
||||||
|
// 理论成本(标准BOM)
|
||||||
|
const stdItems = bd.flatMap(cat => cat.items.map(item => ({ name: item.label, stdCost: item.stdCost })));
|
||||||
|
const totalStd = stdItems.reduce((s, i) => s + i.stdCost, 0);
|
||||||
|
|
||||||
|
// 各产线损耗调整成本
|
||||||
|
const lineTotals: number[] = lines.map(line =>
|
||||||
|
line.steps.reduce((s, st) => s + st.cost / Math.max(line.yieldRate, 0.5), 0)
|
||||||
|
);
|
||||||
|
const avgLineTotal = lineTotals.length > 0 ? lineTotals.reduce((s, c) => s + c, 0) / lineTotals.length : totalStd;
|
||||||
|
|
||||||
|
// 每个零件的对比数据
|
||||||
|
const parts: PartRow[] = stdItems.map(item => {
|
||||||
|
const ratio = totalStd > 0 ? item.stdCost / totalStd : 0;
|
||||||
|
const lc: [number, number, number] = [
|
||||||
|
Math.round((lineTotals[0] || item.stdCost) * ratio),
|
||||||
|
Math.round((lineTotals[1] || item.stdCost) * ratio),
|
||||||
|
Math.round((lineTotals[2] || item.stdCost) * ratio),
|
||||||
|
];
|
||||||
|
const avg = Math.round((lc[0] + lc[1] + lc[2]) / 3);
|
||||||
|
const v = avg - item.stdCost;
|
||||||
|
return { name: item.name, stdCost: item.stdCost, lineCosts: lc, avgCost: avg, variance: v, variancePct: item.stdCost > 0 ? v / item.stdCost : 0 };
|
||||||
|
}).sort((a, b) => Math.abs(b.variance) - Math.abs(a.variance));
|
||||||
|
|
||||||
|
const totalVar = Math.round(avgLineTotal - totalStd);
|
||||||
|
const totalVarPct = totalStd > 0 ? totalVar / totalStd : 0;
|
||||||
|
const maxAbsVar = Math.max(...parts.map(p => Math.abs(p.variance)), 1);
|
||||||
|
|
||||||
|
// 找最优/最差产线
|
||||||
|
const bestLineIdx = lineTotals.indexOf(Math.min(...lineTotals));
|
||||||
|
const worstLineIdx = lineTotals.indexOf(Math.max(...lineTotals));
|
||||||
|
|
||||||
|
// ── 样式工具 ──
|
||||||
|
const varColor = (v: number) => v > 500 ? tokens.danger : v > 100 ? tokens.warning : tokens.success;
|
||||||
|
const headCell = { padding: '5px 8px', fontSize: 9, fontWeight: 700, color: tokens.textMuted, textAlign: 'center' as const, borderBottom: `1px solid ${tokens.border}`, background: isLight ? tokens.tableHeaderBg : 'rgba(0,20,60,0.5)' };
|
||||||
|
const dataCell = { padding: '5px 8px', fontSize: 10, fontFamily: 'monospace' as const, textAlign: 'right' as const, borderBottom: `1px solid ${tokens.border}44` };
|
||||||
|
|
||||||
|
// ── KPI 卡片样式 ──
|
||||||
|
const kpiCard = (color: string) => ({
|
||||||
|
flex: 1, padding: '10px 12px', borderRadius: 8,
|
||||||
|
background: isLight ? `${color}08` : `${color}0a`,
|
||||||
|
border: `1px solid ${color}33`,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-scroll" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowY: 'auto', minHeight: 0, gap: 10, padding: '10px 14px' }}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 14 }}>📊</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: tokens.text }}>理论成本 vs 产线损耗 差异分析</span>
|
||||||
|
<span style={{ fontSize: 10, color: tokens.textMuted, padding: '2px 8px', borderRadius: 4, background: tokens.panelBgAlt }}>
|
||||||
|
{order.customer} · {order.productCategory}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ KPI 汇总卡片 ═══ */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
|
<div style={kpiCard(tokens.accent)}>
|
||||||
|
<div style={{ fontSize: 9, color: tokens.textMuted, fontWeight: 600, marginBottom: 4 }}>理论标准总成本</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 800, color: tokens.accent, fontFamily: 'monospace' }}>¥{fmt(totalStd)}</div>
|
||||||
|
<div style={{ fontSize: 9, color: tokens.textMuted, marginTop: 2 }}>{parts.length} 个零件</div>
|
||||||
|
</div>
|
||||||
|
<div style={kpiCard(tokens.success)}>
|
||||||
|
<div style={{ fontSize: 9, color: tokens.textMuted, fontWeight: 600, marginBottom: 4 }}>最优产线</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 800, color: tokens.success, fontFamily: 'monospace' }}>¥{fmt(Math.round(lineTotals[bestLineIdx] || 0))}</div>
|
||||||
|
<div style={{ fontSize: 9, color: tokens.success, marginTop: 2, fontWeight: 600 }}>{LINE_NAMES[bestLineIdx]} · 良率 {pct(lines[bestLineIdx]?.yieldRate || 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={kpiCard(tokens.danger)}>
|
||||||
|
<div style={{ fontSize: 9, color: tokens.textMuted, fontWeight: 600, marginBottom: 4 }}>最差产线</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 800, color: tokens.danger, fontFamily: 'monospace' }}>¥{fmt(Math.round(lineTotals[worstLineIdx] || 0))}</div>
|
||||||
|
<div style={{ fontSize: 9, color: tokens.danger, marginTop: 2, fontWeight: 600 }}>{LINE_NAMES[worstLineIdx]} · 良率 {pct(lines[worstLineIdx]?.yieldRate || 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={kpiCard(varColor(totalVar))}>
|
||||||
|
<div style={{ fontSize: 9, color: tokens.textMuted, fontWeight: 600, marginBottom: 4 }}>平均损耗差异</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 800, color: varColor(totalVar), fontFamily: 'monospace' }}>
|
||||||
|
{totalVar >= 0 ? '+' : ''}¥{fmt(totalVar)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 9, color: varColor(totalVar), marginTop: 2, fontWeight: 600 }}>
|
||||||
|
{totalVarPct >= 0 ? '+' : ''}{(totalVarPct * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ 蝴蝶图(Tornado Chart)═══ */}
|
||||||
|
<div style={{ borderRadius: 8, overflow: 'hidden', border: `1px solid ${tokens.border}`, flexShrink: 0 }}>
|
||||||
|
<div style={{ padding: '6px 12px', background: isLight ? 'rgba(30,60,130,0.04)' : 'rgba(0,20,60,0.3)', borderBottom: `1px solid ${tokens.border}`, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ width: 3, height: 12, borderRadius: 2, background: tokens.accent }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: tokens.text }}>零件成本差异排名</span>
|
||||||
|
<span style={{ fontSize: 9, color: tokens.textMuted }}>按平均损耗差异绝对值排序</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '8px 12px' }}>
|
||||||
|
{parts.map((p, idx) => {
|
||||||
|
const barPct = Math.abs(p.variance) / maxAbsVar;
|
||||||
|
const isOver = p.variance > 0;
|
||||||
|
const c = varColor(p.variance);
|
||||||
|
const barW = Math.max(4, barPct * 100);
|
||||||
|
return (
|
||||||
|
<div key={p.name} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 0, padding: '5px 0',
|
||||||
|
borderBottom: idx < parts.length - 1 ? `1px solid ${tokens.border}22` : 'none',
|
||||||
|
}}>
|
||||||
|
{/* 零件名 */}
|
||||||
|
<div style={{ width: 52, flexShrink: 0, fontSize: 10, fontWeight: 600, color: tokens.text, textAlign: 'right', paddingRight: 8 }}>
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
{/* 理论成本 */}
|
||||||
|
<div style={{ width: 58, flexShrink: 0, fontSize: 9, fontFamily: 'monospace', color: tokens.textMuted, textAlign: 'right', paddingRight: 8 }}>
|
||||||
|
¥{fmt(p.stdCost)}
|
||||||
|
</div>
|
||||||
|
{/* 蝴蝶图条形区域 */}
|
||||||
|
<div style={{ flex: 1, position: 'relative', height: 18, display: 'flex', alignItems: 'center' }}>
|
||||||
|
{/* 中轴线 */}
|
||||||
|
<div style={{ position: 'absolute', left: 0, right: 0, top: '50%', height: 1, background: `${tokens.textMuted}33` }} />
|
||||||
|
{/* 条形 - 超支向右(红),节约向左(绿) */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: isOver ? '50%' : `${50 - barW / 2}%`,
|
||||||
|
width: `${barW / 2}%`,
|
||||||
|
minWidth: 3,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: isOver ? '0 3px 3px 0' : '3px 0 0 3px',
|
||||||
|
background: isOver
|
||||||
|
? `linear-gradient(90deg, ${c}88, ${c})`
|
||||||
|
: `linear-gradient(90deg, ${c}, ${c}88)`,
|
||||||
|
transition: 'width 0.4s ease',
|
||||||
|
}} />
|
||||||
|
{/* 中心零线标记 */}
|
||||||
|
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 1, background: `${tokens.textMuted}55`, transform: 'translateX(-0.5px)' }} />
|
||||||
|
</div>
|
||||||
|
{/* 差异值 */}
|
||||||
|
<div style={{ width: 72, flexShrink: 0, fontSize: 10, fontFamily: 'monospace', fontWeight: 700, color: c, textAlign: 'left', paddingLeft: 8 }}>
|
||||||
|
{isOver ? '▲' : '▼'} ¥{fmt(Math.abs(p.variance))}
|
||||||
|
</div>
|
||||||
|
{/* 差异率 */}
|
||||||
|
<div style={{ width: 50, flexShrink: 0, fontSize: 9, fontFamily: 'monospace', fontWeight: 600, color: c, textAlign: 'right', opacity: 0.85 }}>
|
||||||
|
{p.variancePct >= 0 ? '+' : ''}{(p.variancePct * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* 图例 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 20, marginTop: 6, paddingTop: 6, borderTop: `1px solid ${tokens.border}22` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<div style={{ width: 12, height: 6, borderRadius: 2, background: tokens.success }} />
|
||||||
|
<span style={{ fontSize: 8, color: tokens.textMuted }}>▼ 成本节约</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<div style={{ width: 12, height: 6, borderRadius: 2, background: tokens.warning }} />
|
||||||
|
<span style={{ fontSize: 8, color: tokens.textMuted }}>▲ 轻微偏高</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<div style={{ width: 12, height: 6, borderRadius: 2, background: tokens.danger }} />
|
||||||
|
<span style={{ fontSize: 8, color: tokens.textMuted }}>▲ 损耗超标</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ 三产线成本对比表 ═══ */}
|
||||||
|
<div style={{ borderRadius: 8, overflow: 'hidden', border: `1px solid ${tokens.border}`, flexShrink: 0 }}>
|
||||||
|
<div style={{ padding: '6px 12px', background: isLight ? 'rgba(30,60,130,0.04)' : 'rgba(0,20,60,0.3)', borderBottom: `1px solid ${tokens.border}`, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ width: 3, height: 12, borderRadius: 2, background: tokens.accent }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: tokens.text }}>零件成本明细对比</span>
|
||||||
|
<span style={{ fontSize: 9, color: tokens.textMuted }}>理论标准 vs 各产线损耗调整成本</span>
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 10 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ ...headCell, textAlign: 'left', width: 56 }}>零件</th>
|
||||||
|
<th style={headCell}>理论成本</th>
|
||||||
|
{LINE_NAMES.map((n, i) => (
|
||||||
|
<th key={i} style={{ ...headCell, color: LINE_COLORS[i] }}>{n}</th>
|
||||||
|
))}
|
||||||
|
<th style={headCell}>平均差异</th>
|
||||||
|
<th style={headCell}>差异率</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{parts.map((p, idx) => {
|
||||||
|
const c = varColor(p.variance);
|
||||||
|
return (
|
||||||
|
<tr key={p.name} style={{ background: idx % 2 === 0 ? 'transparent' : (isLight ? 'rgba(0,40,120,0.015)' : 'rgba(0,20,60,0.08)') }}>
|
||||||
|
<td style={{ ...dataCell, textAlign: 'left', fontWeight: 600, color: tokens.text }}>{p.name}</td>
|
||||||
|
<td style={{ ...dataCell, color: tokens.accent, fontWeight: 600 }}>¥{fmt(p.stdCost)}</td>
|
||||||
|
{p.lineCosts.map((lc, li) => {
|
||||||
|
const diff = lc - p.stdCost;
|
||||||
|
const dc = diff > 500 ? tokens.danger : diff > 100 ? tokens.warning : tokens.success;
|
||||||
|
return (
|
||||||
|
<td key={li} style={{ ...dataCell, color: dc, fontWeight: Math.abs(diff) > 500 ? 700 : 500 }}>
|
||||||
|
¥{fmt(lc)}
|
||||||
|
{diff !== 0 && <span style={{ fontSize: 8, marginLeft: 2 }}>({diff > 0 ? '+' : ''}{fmt(diff)})</span>}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td style={{ ...dataCell, color: c, fontWeight: 700 }}>
|
||||||
|
{p.variance >= 0 ? '+' : ''}¥{fmt(p.variance)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...dataCell, color: c, fontWeight: 700 }}>
|
||||||
|
{p.variancePct >= 0 ? '+' : ''}{(p.variancePct * 100).toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* 合计 */}
|
||||||
|
<tr style={{ background: isLight ? 'rgba(30,60,130,0.05)' : 'rgba(0,20,60,0.25)', fontWeight: 700 }}>
|
||||||
|
<td style={{ ...dataCell, textAlign: 'left', fontWeight: 700, borderTop: `1px solid ${tokens.border}` }}>合计</td>
|
||||||
|
<td style={{ ...dataCell, color: tokens.accent, fontWeight: 700, borderTop: `1px solid ${tokens.border}` }}>¥{fmt(totalStd)}</td>
|
||||||
|
{lineTotals.map((lc, i) => (
|
||||||
|
<td key={i} style={{ ...dataCell, fontWeight: 700, borderTop: `1px solid ${tokens.border}`, color: LINE_COLORS[i] }}>¥{fmt(Math.round(lc))}</td>
|
||||||
|
))}
|
||||||
|
<td style={{ ...dataCell, color: varColor(totalVar), fontWeight: 700, borderTop: `1px solid ${tokens.border}` }}>
|
||||||
|
{totalVar >= 0 ? '+' : ''}¥{fmt(totalVar)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...dataCell, color: varColor(totalVar), fontWeight: 700, borderTop: `1px solid ${tokens.border}` }}>
|
||||||
|
{totalVarPct >= 0 ? '+' : ''}{(totalVarPct * 100).toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══ PMC 洞察总结 ═══ */}
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px', borderRadius: 8,
|
||||||
|
background: `${Math.abs(totalVar) > totalStd * 0.05 ? tokens.danger : tokens.success}08`,
|
||||||
|
border: `1px solid ${Math.abs(totalVar) > totalStd * 0.05 ? tokens.danger : tokens.success}33`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: 14 }}>💡</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: tokens.text }}>PMC 成本控制洞察</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 11, color: tokens.text, lineHeight: 1.8, margin: 0 }}>
|
||||||
|
理论标准成本 <b style={{ color: tokens.accent }}>¥{fmt(totalStd)}</b>,
|
||||||
|
最优产线 <b style={{ color: tokens.success }}>{LINE_NAMES[bestLineIdx]}</b>(¥{fmt(Math.round(lineTotals[bestLineIdx] || 0))})
|
||||||
|
vs 最差产线 <b style={{ color: tokens.danger }}>{LINE_NAMES[worstLineIdx]}</b>(¥{fmt(Math.round(lineTotals[worstLineIdx] || 0))}),
|
||||||
|
产线间最大差额 <b style={{ color: tokens.warning }}>¥{fmt(Math.round((lineTotals[worstLineIdx] || 0) - (lineTotals[bestLineIdx] || 0)))}</b>。
|
||||||
|
</p>
|
||||||
|
{parts.length > 0 && parts[0].variance > 0 && (
|
||||||
|
<p style={{ fontSize: 11, color: tokens.text, lineHeight: 1.8, margin: '4px 0 0' }}>
|
||||||
|
差异最大的零件:<b style={{ color: tokens.danger }}>{parts[0].name}</b>
|
||||||
|
(平均损耗增加 ¥{fmt(parts[0].variance)},+{(parts[0].variancePct * 100).toFixed(1)}%),
|
||||||
|
建议优先安排在自动线产线加工以降低损耗成本。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{parts.length > 1 && parts[1].variance > 0 && (
|
||||||
|
<p style={{ fontSize: 11, color: tokens.text, lineHeight: 1.8, margin: '2px 0 0' }}>
|
||||||
|
其次为 <b style={{ color: tokens.warning }}>{parts[1].name}</b>
|
||||||
|
(+¥{fmt(parts[1].variance)},+{(parts[1].variancePct * 100).toFixed(1)}%),
|
||||||
|
可通过提升设备精度或优化工艺参数降低损耗。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
import { useEffect, useRef, useState, type DragEvent } from 'react';
|
||||||
|
import { DEMANDS, type AIDemand, maturityConfig, statusConfig } from '../data/demands';
|
||||||
|
import { COUPONS, couponTypeConfig } from '../data/coupons';
|
||||||
|
import { orgTypeConfig, type Translator } from '../data/translators';
|
||||||
|
import TechIcon, { type TechIconName } from './TechIcons';
|
||||||
|
|
||||||
|
interface CenterDemandDetailProps {
|
||||||
|
demand: AIDemand | null;
|
||||||
|
translator: Translator | null;
|
||||||
|
resetKey?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentModule = 'maturity' | 'translator' | 'roadmap' | 'coupon';
|
||||||
|
|
||||||
|
const industryColors: Record<string, string> = {
|
||||||
|
'智能电气': '#00d4ff',
|
||||||
|
'鞋业服装': '#ff6b9d',
|
||||||
|
'汽车零部件': '#ff8c42',
|
||||||
|
'工业泵阀': '#7b61ff',
|
||||||
|
'智能装备': '#4fc3f7',
|
||||||
|
'新能源': '#00e599',
|
||||||
|
'新材料': '#ffb340',
|
||||||
|
'智能物联': '#35d0ba',
|
||||||
|
'眼镜': '#9bdcff',
|
||||||
|
'食品农产品': '#8bd450',
|
||||||
|
'低空经济': '#b58cff',
|
||||||
|
};
|
||||||
|
|
||||||
|
const capabilityLabels = ['数据就绪', '系统集成', 'AI经验', '业务牵引', '场景清晰'];
|
||||||
|
|
||||||
|
const capabilityScoreMap: Record<string, number[]> = {
|
||||||
|
WZ001: [78, 82, 18, 76, 14],
|
||||||
|
WZ002: [82, 80, 22, 78, 16],
|
||||||
|
WZ003: [66, 18, 12, 72, 80],
|
||||||
|
WZ004: [63, 71, 15, 24, 84],
|
||||||
|
WZ005: [70, 16, 14, 82, 86],
|
||||||
|
WZ006: [52, 18, 12, 74, 79],
|
||||||
|
WZ007: [62, 14, 68, 80, 18],
|
||||||
|
WZ008: [48, 16, 74, 20, 82],
|
||||||
|
WZ009: [58, 24, 14, 76, 81],
|
||||||
|
WZ010: [50, 12, 70, 78, 18],
|
||||||
|
WZ011: [60, 18, 82, 14, 75],
|
||||||
|
WZ012: [61, 20, 78, 15, 72],
|
||||||
|
WZ013: [65, 17, 80, 82, 12],
|
||||||
|
WZ014: [58, 72, 15, 18, 80],
|
||||||
|
WZ015: [45, 12, 18, 77, 72],
|
||||||
|
WZ016: [68, 18, 13, 80, 84],
|
||||||
|
WZ017: [36, 14, 72, 18, 82],
|
||||||
|
WZ018: [40, 16, 18, 75, 82],
|
||||||
|
WZ019: [55, 78, 18, 84, 13],
|
||||||
|
WZ020: [49, 14, 76, 20, 82],
|
||||||
|
WZ021: [90, 86, 82, 78, 18],
|
||||||
|
WZ022: [64, 22, 75, 80, 16],
|
||||||
|
WZ023: [78, 82, 17, 15, 86],
|
||||||
|
WZ024: [72, 78, 18, 84, 15],
|
||||||
|
WZ025: [60, 15, 80, 18, 84],
|
||||||
|
WZ026: [50, 14, 16, 76, 82],
|
||||||
|
WZ027: [76, 84, 18, 80, 15],
|
||||||
|
WZ028: [75, 82, 78, 18, 14],
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleCards: { key: AgentModule; label: string; icon: TechIconName; color: string; desc: string }[] = [
|
||||||
|
{ key: 'maturity', label: 'AI成熟度问诊', icon: 'maturity', color: '#00d4ff', desc: '投放企业需求,生成五维成熟度诊断' },
|
||||||
|
{ key: 'translator', label: 'AI翻译官解析', icon: 'translator', color: '#7b61ff', desc: '投放企业需求,转译业务痛点与技术边界' },
|
||||||
|
{ key: 'roadmap', label: 'AI落地规划', icon: 'roadmap', color: '#00e599', desc: '投放企业需求,生成分阶段实施路线' },
|
||||||
|
{ key: 'coupon', label: '优惠券领取', icon: 'coupon', color: '#ff8c42', desc: '投放企业需求,匹配算力/语料/咨询等券' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function RadarChart({ scores, color }: { scores: number[]; color: string }) {
|
||||||
|
const cx = 60, cy = 60, r = 48;
|
||||||
|
const points = scores.map((v, i) => {
|
||||||
|
const angle = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const pct = Math.max(0.08, Math.min(1, v / 100));
|
||||||
|
return {
|
||||||
|
x: cx + r * pct * Math.cos(angle),
|
||||||
|
y: cy + r * pct * Math.sin(angle),
|
||||||
|
labelX: cx + (r + 18) * Math.cos(angle),
|
||||||
|
labelY: cy + (r + 18) * Math.sin(angle),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const polyStr = points.map(p => `${p.x},${p.y}`).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={150} height={140} viewBox="0 0 150 140">
|
||||||
|
<g transform="translate(15 5)">
|
||||||
|
{[0.25, 0.5, 0.75, 1].map(frac => {
|
||||||
|
const ps = Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
return `${cx + r * frac * Math.cos(a)},${cy + r * frac * Math.sin(a)}`;
|
||||||
|
}).join(' ');
|
||||||
|
return <polygon key={frac} points={ps} fill="none" stroke={`rgba(0,212,255,${frac * 0.1 + 0.06})`} strokeWidth="0.8" />;
|
||||||
|
})}
|
||||||
|
{Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const a = (i / 5) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
return <line key={i} x1={cx} y1={cy} x2={cx + r * Math.cos(a)} y2={cy + r * Math.sin(a)} stroke="rgba(0,212,255,0.12)" strokeWidth="0.8" />;
|
||||||
|
})}
|
||||||
|
<polygon points={polyStr} fill={`${color}20`} stroke={color} strokeWidth="2" style={{ filter: `drop-shadow(0 0 5px ${color}80)` }} />
|
||||||
|
{points.map((p, i) => <circle key={i} cx={p.x} cy={p.y} r="3" fill={color} />)}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<text key={capabilityLabels[i]} x={p.labelX} y={p.labelY} textAnchor="middle" fill="rgba(255,255,255,0.48)" fontSize="8">
|
||||||
|
{scores[i]}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaturityGauge({ score, color }: { score: number; color: string }) {
|
||||||
|
const R = 38, cx = 50, cy = 52;
|
||||||
|
const startAngle = -220 * Math.PI / 180;
|
||||||
|
const endAngle = 40 * Math.PI / 180;
|
||||||
|
const totalAngle = endAngle - startAngle;
|
||||||
|
const progressAngle = startAngle + totalAngle * (score / 100);
|
||||||
|
|
||||||
|
const arcPath = (fromA: number, toA: number, r: number) => {
|
||||||
|
const x1 = cx + r * Math.cos(fromA), y1 = cy + r * Math.sin(fromA);
|
||||||
|
const x2 = cx + r * Math.cos(toA), y2 = cy + r * Math.sin(toA);
|
||||||
|
const large = (toA - fromA) > Math.PI ? 1 : 0;
|
||||||
|
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={100} height={70} viewBox="0 0 100 70">
|
||||||
|
<path d={arcPath(startAngle, endAngle, R)} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="6" strokeLinecap="round" />
|
||||||
|
<path d={arcPath(startAngle, progressAngle, R)} fill="none" stroke={color} strokeWidth="6" strokeLinecap="round" style={{ filter: `drop-shadow(0 0 4px ${color}80)` }} />
|
||||||
|
<text x={cx} y={cy + 4} textAnchor="middle" fill={color} fontSize="16" fontWeight="700">{score}</text>
|
||||||
|
<text x={cx} y={cy + 15} textAnchor="middle" fill="rgba(255,255,255,0.5)" fontSize="8">成熟度评分</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleCard({ card, active, inactive, compact, onDrop }: { card: typeof moduleCards[number]; active?: boolean; inactive?: boolean; compact?: boolean; onDrop: (module: AgentModule, event: DragEvent<HTMLDivElement>) => void }) {
|
||||||
|
const labelColor = inactive ? 'rgba(255,255,255,0.28)' : '#e8f4ff';
|
||||||
|
const descColor = inactive ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.58)';
|
||||||
|
const iconColor = inactive ? 'rgba(255,255,255,0.2)' : card.color;
|
||||||
|
const iconBg = inactive ? 'rgba(255,255,255,0.04)' : `${card.color}12`;
|
||||||
|
const iconBorder = inactive ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${card.color}45`;
|
||||||
|
const cardBg = active ? `${card.color}16` : inactive ? 'rgba(255,255,255,0.02)' : `${card.color}0d`;
|
||||||
|
const cardBorder = active ? `1px solid ${card.color}70` : inactive ? '1px solid rgba(255,255,255,0.08)' : `1px solid ${card.color}38`;
|
||||||
|
const cardShadow = active ? `0 0 26px ${card.color}18` : inactive ? 'none' : `inset 0 0 24px ${card.color}08`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={event => { event.preventDefault(); event.currentTarget.style.background = `${card.color}18`; }}
|
||||||
|
onDragLeave={event => { event.currentTarget.style.background = cardBg; }}
|
||||||
|
onDrop={event => onDrop(card.key, event)}
|
||||||
|
style={{
|
||||||
|
minHeight: compact ? 92 : 150,
|
||||||
|
padding: compact ? '14px 16px' : '24px 26px',
|
||||||
|
borderRadius: compact ? 14 : 18,
|
||||||
|
background: cardBg,
|
||||||
|
border: cardBorder,
|
||||||
|
boxShadow: cardShadow,
|
||||||
|
color: 'rgba(255,255,255,0.86)',
|
||||||
|
animation: 'card-in 0.35s both',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: compact ? 'row' : 'column',
|
||||||
|
alignItems: compact ? 'center' : 'flex-start',
|
||||||
|
gap: compact ? 12 : 14,
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: compact ? 44 : 68,
|
||||||
|
height: compact ? 44 : 68,
|
||||||
|
borderRadius: compact ? 12 : 18,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: iconBg, border: iconBorder,
|
||||||
|
boxShadow: inactive ? 'none' : `0 0 18px ${card.color}20`,
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.25s ease',
|
||||||
|
}}>
|
||||||
|
<TechIcon name={card.icon} color={iconColor} size={compact ? 30 : 46} />
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ color: labelColor, fontSize: compact ? 14 : 18, fontWeight: 850, letterSpacing: '0.02em', transition: 'color 0.25s ease' }}>{card.label}</div>
|
||||||
|
<div style={{ color: descColor, fontSize: compact ? 12 : 14, lineHeight: 1.6, marginTop: compact ? 3 : 8, transition: 'color 0.25s ease' }}>{card.desc}</div>
|
||||||
|
{!compact && !inactive && <div style={{ color: card.color, fontSize: 12, fontWeight: 700, marginTop: 12 }}>拖入左侧企业AI需求卡片启动</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProcessingScreen({ module, demand }: { module: AgentModule; demand: AIDemand }) {
|
||||||
|
const card = moduleCards.find(c => c.key === module)!;
|
||||||
|
const steps = [
|
||||||
|
'解析企业 AI 需求场景…',
|
||||||
|
'调取数据资产与基础评估…',
|
||||||
|
'匹配温州市典型场景参考指引…',
|
||||||
|
'生成分析报告…',
|
||||||
|
];
|
||||||
|
const [stepIdx, setStepIdx] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setStepIdx(i => (i + 1) % steps.length), 480);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
flex: 1, minWidth: 0,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '14px 18px', gap: 12, overflow: 'hidden',
|
||||||
|
perspective: '800px',
|
||||||
|
}}>
|
||||||
|
{/* 旋转卡片 */}
|
||||||
|
<div className="module-processing" style={{
|
||||||
|
width: 200, height: 160,
|
||||||
|
borderRadius: 22,
|
||||||
|
background: `linear-gradient(135deg, ${card.color}22, ${card.color}08)`,
|
||||||
|
border: `1.5px solid ${card.color}60`,
|
||||||
|
boxShadow: `0 0 48px ${card.color}30, inset 0 0 32px ${card.color}10`,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 12, position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* 扫描线 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 0, right: 0, height: 2,
|
||||||
|
background: `linear-gradient(90deg, transparent, ${card.color}80, transparent)`,
|
||||||
|
animation: 'scan-sweep 1.2s linear infinite',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
{/* 轨道环 - 外圈 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', width: 140, height: 140,
|
||||||
|
border: `1px dashed ${card.color}30`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'orbit-ring 3s linear infinite',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: -3, left: '50%', marginLeft: -3,
|
||||||
|
width: 6, height: 6, borderRadius: '50%',
|
||||||
|
background: card.color,
|
||||||
|
boxShadow: `0 0 8px ${card.color}`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
{/* 轨道环 - 内圈 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', width: 90, height: 90,
|
||||||
|
border: `1px dashed ${card.color}20`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'orbit-ring-rev 2s linear infinite',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: -3, left: '50%', marginLeft: -3,
|
||||||
|
width: 5, height: 5, borderRadius: '50%',
|
||||||
|
background: `${card.color}cc`,
|
||||||
|
boxShadow: `0 0 6px ${card.color}`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<TechIcon name={card.icon} color={card.color} size={38} />
|
||||||
|
<div style={{ color: card.color, fontSize: 13, fontWeight: 700, letterSpacing: '0.04em' }}>{card.label}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 处理中文字 */}
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, marginTop: 8, letterSpacing: '0.02em' }}>
|
||||||
|
{demand.company} · {steps[stepIdx]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 点点动画 */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<div key={i} style={{
|
||||||
|
width: 7, height: 7, borderRadius: '50%',
|
||||||
|
background: card.color,
|
||||||
|
animation: `processing-dots 1.2s ease-in-out ${i * 0.2}s infinite`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyWorkspace({ onDrop }: { onDrop: (module: AgentModule, event: DragEvent<HTMLDivElement>) => void }) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: 30 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(220px, 1fr))', gap: 18 }}>
|
||||||
|
{moduleCards.map(card => <ModuleCard key={card.key} card={card} onDrop={onDrop} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DemandSummary({ demand }: { demand: AIDemand }) {
|
||||||
|
const mCfg = maturityConfig[demand.maturityLevel];
|
||||||
|
const sCfg = statusConfig[demand.status];
|
||||||
|
const indColor = industryColors[demand.industry] || '#00d4ff';
|
||||||
|
const capabilityScores = capabilityScoreMap[demand.id] ?? [demand.dataReadiness, 20, 16, Math.max(70, demand.maturityScore), 82];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 14, background: 'rgba(0,20,60,0.6)', border: '1px solid rgba(0,212,255,0.2)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<MaturityGauge score={demand.maturityScore} color={mCfg.color} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ color: '#e8f4ff', fontSize: 18, fontWeight: 800 }}>{demand.company}</span>
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: `${sCfg.color}18`, border: `1px solid ${sCfg.color}40`, color: sCfg.color, fontSize: 12, fontWeight: 700 }}>{sCfg.label}</span>
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: `${indColor}18`, border: `1px solid ${indColor}35`, color: indColor, fontSize: 12, fontWeight: 700 }}>{demand.industry}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.76)', fontSize: 13, lineHeight: 1.65, margin: '8px 0 0' }}>{demand.painPoint}</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<RadarChart scores={capabilityScores} color={mCfg.color} />
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: 10, marginTop: -4 }}>AI能力图谱</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleWorkspace({ demand, module, onDrop }: { demand: AIDemand; module: AgentModule; onDrop: (module: AgentModule, event: DragEvent<HTMLDivElement>) => void }) {
|
||||||
|
const activeCard = moduleCards.find(card => card.key === module)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', padding: '14px 18px', gap: 12, overflow: 'hidden' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(120px, 1fr))', gap: 10, flexShrink: 0 }}>
|
||||||
|
{moduleCards.map(card => <ModuleCard key={card.key} card={card} inactive={!!module && card.key !== module} active={card.key === module} compact onDrop={onDrop} />)}
|
||||||
|
</div>
|
||||||
|
<DemandSummary demand={demand} />
|
||||||
|
<div className="space-scroll content-reveal" style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '16px 18px', borderRadius: 16, background: `${activeCard.color}08`, border: `1px solid ${activeCard.color}26`, boxShadow: `inset 0 0 32px ${activeCard.color}08` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||||
|
<TechIcon name={activeCard.icon} color={activeCard.color} size={34} />
|
||||||
|
<div>
|
||||||
|
<div style={{ color: activeCard.color, fontSize: 22, fontWeight: 850 }}>{activeCard.label}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.52)', fontSize: 12 }}>Agentic Space 已接收:{demand.company}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{module === 'maturity' && <MaturityModule demand={demand} />}
|
||||||
|
{module === 'translator' && <TranslatorModule demand={demand} />}
|
||||||
|
{module === 'roadmap' && <RoadmapModule demand={demand} />}
|
||||||
|
{module === 'coupon' && <CouponModule demand={demand} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaturityModule({ demand }: { demand: AIDemand }) {
|
||||||
|
const scores = capabilityScoreMap[demand.id] ?? [demand.dataReadiness, 20, 16, Math.max(70, demand.maturityScore), 82];
|
||||||
|
const mCfg = maturityConfig[demand.maturityLevel];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||||||
|
<div style={{ padding: 16, borderRadius: 12, background: 'rgba(0,212,255,0.06)', border: '1px solid rgba(0,212,255,0.2)' }}>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 16, fontWeight: 800, marginBottom: 10 }}>五维成熟度问诊结论</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.82)', fontSize: 14, lineHeight: 1.8, margin: 0 }}>
|
||||||
|
{demand.company} 当前处于 {mCfg.label},综合评分 {demand.maturityScore}/100。建议先围绕“{demand.scenario}”建立小范围试点,以数据就绪度、系统接入能力和业务KPI闭环作为首批评估指标。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 16, borderRadius: 12, background: 'rgba(0,20,60,0.5)', border: '1px solid rgba(0,212,255,0.16)' }}>
|
||||||
|
{capabilityLabels.map((label, index) => (
|
||||||
|
<div key={label} style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: 'rgba(255,255,255,0.72)', fontSize: 12, marginBottom: 4 }}><span>{label}</span><span>{scores[index]}%</span></div>
|
||||||
|
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${scores[index]}%`, height: '100%', borderRadius: 4, background: `linear-gradient(90deg, ${mCfg.color}88, ${mCfg.color})` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TranslatorModule({ demand }: { demand: AIDemand }) {
|
||||||
|
const mCfg = maturityConfig[demand.maturityLevel];
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: '业务语言 · 痛点描述与量化', icon: 'target' as TechIconName, color: '#ff8c42',
|
||||||
|
text: demand.businessDesc || demand.painPoint,
|
||||||
|
extra: [
|
||||||
|
`核心问题指标:${demand.company}在“${demand.scenario}”场景中,当前${demand.painPoint}。`,
|
||||||
|
`问题导致的业务损失:生产效率受限、人工成本高企、质量波动影响交付稳定性。`,
|
||||||
|
`“理想状态”描述:通过AI技术实现该环节自动化或半自动化,减少人工依赖、提升一致性与可追溯性。`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数据与技术基础评估', icon: 'lab' as TechIconName, color: '#00d4ff',
|
||||||
|
text: demand.technicalDesc || `围绕${demand.scenario},需进一步确认数据来源、系统接口、现场部署环境和安全合规边界。`,
|
||||||
|
extra: [
|
||||||
|
`可用数据集:${demand.dataReadiness >= 70 ? '企业已具备一定结构化数据基础,数据覆盖率较高,可支撑AI建模起步' : '数据采集与结构化程度不足,建议先完成数据资产盘点与质量评估'}。`,
|
||||||
|
`现有IT与硬件设施:${demand.dataReadiness >= 60 ? '已有ERP/MES等系统覆盖核心业务流程,系统集成度待进一步提升' : '核心业务系统部署率较低,需优先补齐数字化基础设施'}。`,
|
||||||
|
`数源需求:需进一步确认${demand.scenario}场景下的关键数据字段、采集频率、标注情况和隐私合规要求。`,
|
||||||
|
`算力资源:建议评估本地部署与云端训练的混合方案,满足数据安全与推理效率的双重要求。`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI翻译官 · 需求转译与交付边界', icon: 'translator' as TechIconName, color: '#7b61ff',
|
||||||
|
text: `将“${demand.painPoint}”转译为可交付任务。`,
|
||||||
|
extra: [
|
||||||
|
`核心功能需求:${demand.suggestedSolution || '建议围绕该痛点构建AI辅助能力,明确输入数据格式、模型能力边界、系统集成接口和业务闭环流程'}。`,
|
||||||
|
`MVP验收标准:在试点数据集上,核心指标(准确率/召回率/效率提升)达到可量化目标;完成2-3家合作企业现场试用并收集反馈。`,
|
||||||
|
`最终验收标准:在真实生产场景中连续稳定运行1个月以上,核心KPI相比人工方式提升≥30%,方案具备向同产业其他企业复制推广能力。`,
|
||||||
|
`合作模式:建议采用“制造企业+AI翻译官+技术服务商”联合体模式,企业开放场景与数据,翻译官负责需求转译与技术边界定义,服务商负责交付实施。`,
|
||||||
|
`企业可投入资源:专项预算${demand.budget}、试点产线与真实生产环境、业务专家全程配合需求沟通与方案验证。`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.title} style={{ padding: '14px 16px', borderRadius: 12, background: `${item.color}08`, border: `1px solid ${item.color}22` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: item.color, fontSize: 15, fontWeight: 800, marginBottom: 8 }}><TechIcon name={item.icon} color={item.color} size={20} />{item.title}</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.88)', fontSize: 14, lineHeight: 1.8, margin: '0 0 8px', fontWeight: 600 }}>{item.text}</p>
|
||||||
|
{item.extra?.map((line, i) => (
|
||||||
|
<p key={i} style={{ color: 'rgba(255,255,255,0.72)', fontSize: 13, lineHeight: 1.75, margin: '0 0 4px', paddingLeft: 8, borderLeft: `2px solid ${item.color}30` }}>{line}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ padding: '10px 14px', borderRadius: 10, background: `${mCfg.color}08`, border: `1px solid ${mCfg.color}20` }}>
|
||||||
|
<div style={{ color: mCfg.color, fontSize: 13, fontWeight: 800, marginBottom: 6 }}>成熟度等级 {demand.maturityLevel} · 实施建议</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, lineHeight: 1.7, margin: 0 }}>
|
||||||
|
{demand.maturityLevel === 'L1' && '当前数据基础薄弱,建议先完成核心业务系统部署和数据采集体系建设,再启动AI场景试点。可先申领咨询券进行成熟度诊断。'}
|
||||||
|
{demand.maturityLevel === 'L2' && '具备初步数字化基础,建议聚焦1-2个高价值场景做小规模验证,同步补强数据治理。推荐先引入AI翻译官完成需求精化。'}
|
||||||
|
{demand.maturityLevel === 'L3' && '已有一定AI探索经验,建议推进场景规模化复制,重点打通数据孤岛和建设知识库。可申请算力券+工具券加速开发。'}
|
||||||
|
{demand.maturityLevel === 'L4' && 'AI能力较为成熟,建议构建自主AI开发平台,推进智能决策支持系统。可探索云边端混合部署与国产化适配。'}
|
||||||
|
{demand.maturityLevel === 'L5' && '行业AI领先水平,建议向AI驱动业务创新方向演进,输出行业解决方案,探索AI商业化与产业链赋能。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoadmapModule({ demand }: { demand: AIDemand }) {
|
||||||
|
const stages = [
|
||||||
|
{ phase: '阶段一', title: '现状诊断', duration: '1-2个月', color: '#ffb340', items: ['AI成熟度评估', '数据资产盘点', '场景识别优先级'], icon: 'analytics' as TechIconName },
|
||||||
|
{ phase: '阶段二', title: '需求精化', duration: '1-3个月', color: '#00d4ff', items: ['AI翻译官对接', '场景可行性分析', '技术方案评审'], icon: 'target' as TechIconName },
|
||||||
|
{ phase: '阶段三', title: '试点验证', duration: '3-6个月', color: '#7b61ff', items: ['小规模试点部署', 'ROI效果追踪', '方案迭代优化'], icon: 'lab' as TechIconName },
|
||||||
|
{ phase: '阶段四', title: '规模推广', duration: '6-12个月', color: '#00e599', items: ['扩大应用范围', '建立AI运维体系', '复制成功经验'], icon: 'deploy' as TechIconName },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.82)', fontSize: 14, lineHeight: 1.7, margin: '0 0 14px' }}>
|
||||||
|
目标场景:{demand.scenario}|预算参考:{demand.budget}|期望周期:{demand.timeline}。{demand.suggestedSolution || '建议采用“小切口试点、可量化验收、可复制推广”的温州AI+制造实施路径。'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(150px, 1fr))', gap: 12 }}>
|
||||||
|
{stages.map(stage => (
|
||||||
|
<div key={stage.phase} style={{ padding: '14px 14px', borderRadius: 12, background: `${stage.color}08`, border: `1px solid ${stage.color}25` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<TechIcon name={stage.icon} color={stage.color} size={24} />
|
||||||
|
<div>
|
||||||
|
<div style={{ color: stage.color, fontSize: 12, fontWeight: 800 }}>{stage.phase}</div>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 15, fontWeight: 800 }}>{stage.title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.45)', fontSize: 11, marginBottom: 8 }}>周期 {stage.duration}</div>
|
||||||
|
{stage.items.map(item => <div key={item} style={{ color: 'rgba(255,255,255,0.76)', fontSize: 12, lineHeight: 1.7 }}>· {item}</div>)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CouponModule({ demand }: { demand: AIDemand }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.78)', fontSize: 14, lineHeight: 1.7, marginBottom: 14 }}>
|
||||||
|
已按 {demand.region}、{demand.industry}、{demand.scenario} 自动匹配温州市AI+制造优惠券,建议优先申领咨询券、算力券和工具券。
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(180px, 1fr))', gap: 12 }}>
|
||||||
|
{COUPONS.map(coupon => {
|
||||||
|
const cfg = couponTypeConfig[coupon.type];
|
||||||
|
return (
|
||||||
|
<div key={coupon.id} style={{ padding: '14px 14px', borderRadius: 12, background: `${coupon.color}08`, border: `1px solid ${coupon.color}25` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<TechIcon name={(coupon.icon as TechIconName) || 'coupon'} color={coupon.color} size={26} />
|
||||||
|
<span style={{ padding: '2px 7px', borderRadius: 5, background: cfg.bg, color: cfg.color, fontSize: 11, fontWeight: 800 }}>{cfg.label}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#e8f4ff', fontSize: 14, fontWeight: 800 }}>{coupon.title}</div>
|
||||||
|
<div style={{ color: coupon.color, fontSize: 22, fontWeight: 850, margin: '5px 0 3px' }}>{coupon.value}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.58)', fontSize: 12, lineHeight: 1.55 }}>{coupon.condition}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.42)', fontSize: 11, marginTop: 8, display: 'flex', alignItems: 'center', gap: 5 }}><TechIcon name="calendar" color="rgba(255,255,255,0.4)" size={12} />{coupon.expiry} · 余{coupon.remaining}张</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TranslatorWorkspace({ translator }: { translator: Translator }) {
|
||||||
|
const orgCfg = orgTypeConfig[translator.orgType];
|
||||||
|
const availabilityConfig = {
|
||||||
|
available: { label: '可接洽', color: '#00e599' },
|
||||||
|
busy: { label: '忙碌中', color: '#ffb340' },
|
||||||
|
offline: { label: '离线', color: 'rgba(255,255,255,0.35)' },
|
||||||
|
}[translator.availability];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', padding: '16px 18px', gap: 12, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '18px 20px', borderRadius: 14, background: `linear-gradient(135deg, ${translator.color}16 0%, rgba(0,20,60,0.72) 100%)`, border: `1px solid ${translator.color}35`, boxShadow: `0 0 28px ${translator.color}16`, display: 'flex', gap: 18, alignItems: 'flex-start', flexShrink: 0 }}>
|
||||||
|
<div style={{ width: 74, height: 74, borderRadius: 18, background: `linear-gradient(135deg, ${translator.color}26, ${translator.accentColor}18)`, border: `1px solid ${translator.color}55`, color: translator.color, fontSize: 30, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: `0 0 18px ${translator.color}30`, flexShrink: 0 }}>{translator.avatar}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ color: '#e8f4ff', fontSize: 22, fontWeight: 800 }}>{translator.name}</span>
|
||||||
|
<span style={{ padding: '3px 9px', borderRadius: 7, background: orgCfg.bg, border: `1px solid ${orgCfg.color}40`, color: orgCfg.color, fontSize: 12, fontWeight: 700 }}>{orgCfg.label}</span>
|
||||||
|
<span style={{ padding: '3px 9px', borderRadius: 7, background: `${availabilityConfig.color}18`, border: `1px solid ${availabilityConfig.color}40`, color: availabilityConfig.color, fontSize: 12, fontWeight: 700 }}>{availabilityConfig.label}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: translator.color, fontSize: 15, fontWeight: 700, marginTop: 4 }}>{translator.title}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.62)', fontSize: 13, marginTop: 3 }}>{translator.organization}</div>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.86)', fontSize: 14, lineHeight: 1.8, margin: '12px 0 0' }}>{translator.bio}</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexShrink: 0 }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '8px 12px', borderRadius: 10, background: 'rgba(255,179,64,0.08)', border: '1px solid rgba(255,179,64,0.25)' }}>
|
||||||
|
<div style={{ color: '#ffb340', fontSize: 20, fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5 }}><TechIcon name="star" color="#ffb340" size={18} />{translator.rating}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11 }}>专家评分</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', padding: '8px 12px', borderRadius: 10, background: `${translator.color}0d`, border: `1px solid ${translator.color}30` }}>
|
||||||
|
<div style={{ color: translator.color, fontSize: 20, fontWeight: 800 }}>{translator.cases}</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11 }}>服务案例</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', display: 'grid', gridTemplateColumns: '1.1fr 0.9fr', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 12, background: 'rgba(123,97,255,0.06)', border: '1px solid rgba(123,97,255,0.2)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#c8b4ff', fontSize: 14, fontWeight: 800, marginBottom: 10 }}><TechIcon name="translator" color="#7b61ff" size={18} /> 专长领域</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>{translator.expertise.map(item => <span key={item} style={{ padding: '6px 10px', borderRadius: 18, background: `${translator.color}12`, border: `1px solid ${translator.color}30`, color: translator.color, fontSize: 13, fontWeight: 700 }}>{item}</span>)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 12, background: 'rgba(0,212,255,0.06)', border: '1px solid rgba(0,212,255,0.18)' }}>
|
||||||
|
<div style={{ color: '#00d4ff', fontSize: 14, fontWeight: 800, marginBottom: 10 }}>适配温州产业</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>{translator.industries.map(item => <span key={item} style={{ padding: '6px 10px', borderRadius: 8, background: 'rgba(0,212,255,0.08)', border: '1px solid rgba(0,212,255,0.2)', color: 'rgba(255,255,255,0.82)', fontSize: 13 }}>{item}</span>)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 12, background: 'rgba(0,229,153,0.05)', border: '1px solid rgba(0,229,153,0.18)' }}>
|
||||||
|
<div style={{ color: '#00e599', fontSize: 14, fontWeight: 800, marginBottom: 10 }}>专业认证</div>
|
||||||
|
{translator.certifications.map(item => <div key={item} style={{ color: 'rgba(255,255,255,0.78)', fontSize: 13, lineHeight: 1.8 }}>· {item}</div>)}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 16px', borderRadius: 12, background: 'rgba(255,140,66,0.05)', border: '1px solid rgba(255,140,66,0.18)' }}>
|
||||||
|
<div style={{ color: '#ff8c42', fontSize: 14, fontWeight: 800, marginBottom: 10 }}>推荐对接方式</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.78)', fontSize: 13, lineHeight: 1.8 }}>· 企业提交场景痛点、现有系统、数据样本与产线约束条件<br />· AI翻译官输出业务语言版需求说明,明确痛点量化指标与“理想状态”目标<br />· 翻译官定义技术边界:输入数据格式、模型能力范围、系统集成接口、验收KPI<br />· 输出MVP验收标准与最终项目验收标准,确保可量化、可追踪<br />· 进入落地规划、优惠券匹配和服务商对接,形成“企业+翻译官+服务商”联合体</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DemandDetailView({ demand }: { demand: AIDemand }) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', padding: '10px 16px', gap: 10, overflow: 'hidden' }}>
|
||||||
|
<DemandSummary demand={demand} />
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', flexShrink: 0 }}>
|
||||||
|
{demand.tags.map(tag => <span key={tag} style={{ padding: '3px 10px', borderRadius: 20, background: 'rgba(0,212,255,0.08)', border: '1px solid rgba(0,212,255,0.2)', color: '#a8d8ff', fontSize: 12 }}>#{tag}</span>)}
|
||||||
|
</div>
|
||||||
|
<TranslatorModule demand={demand} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CenterDemandDetail({ demand, translator, resetKey = 0 }: CenterDemandDetailProps) {
|
||||||
|
const [activeModule, setActiveModule] = useState<AgentModule | null>(null);
|
||||||
|
const [moduleDemand, setModuleDemand] = useState<AIDemand | null>(null);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [pendingModule, setPendingModule] = useState<AgentModule | null>(null);
|
||||||
|
const [pendingDemand, setPendingDemand] = useState<AIDemand | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveModule(null);
|
||||||
|
setModuleDemand(null);
|
||||||
|
setProcessing(false);
|
||||||
|
setPendingModule(null);
|
||||||
|
setPendingDemand(null);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (demand) {
|
||||||
|
setActiveModule(null);
|
||||||
|
setModuleDemand(null);
|
||||||
|
setProcessing(false);
|
||||||
|
setPendingModule(null);
|
||||||
|
setPendingDemand(null);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
}, [demand?.id]);
|
||||||
|
|
||||||
|
const handleModuleDrop = (module: AgentModule, event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const demandId = event.dataTransfer.getData('application/x-ai-demand-id');
|
||||||
|
const droppedDemand = DEMANDS.find(item => item.id === demandId);
|
||||||
|
if (!droppedDemand) return;
|
||||||
|
// 先进入处理中动画状态
|
||||||
|
setPendingModule(module);
|
||||||
|
setPendingDemand(droppedDemand);
|
||||||
|
setProcessing(true);
|
||||||
|
setActiveModule(null);
|
||||||
|
setModuleDemand(null);
|
||||||
|
// 2.2s 后再展示真实内容
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setProcessing(false);
|
||||||
|
setModuleDemand(droppedDemand);
|
||||||
|
setActiveModule(module);
|
||||||
|
}, 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (translator) return <TranslatorWorkspace translator={translator} />;
|
||||||
|
if (processing && pendingModule && pendingDemand)
|
||||||
|
return <ProcessingScreen module={pendingModule} demand={pendingDemand} />;
|
||||||
|
if (activeModule && moduleDemand) return <ModuleWorkspace demand={moduleDemand} module={activeModule} onDrop={handleModuleDrop} />;
|
||||||
|
if (!demand) return <EmptyWorkspace onDrop={handleModuleDrop} />;
|
||||||
|
return <DemandDetailView demand={demand} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import type { Order, ProductionLine } from '../data/orders';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
export interface Equipment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
costM: number;
|
||||||
|
timeM: number;
|
||||||
|
yieldM: number;
|
||||||
|
desc: string;
|
||||||
|
color: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
order: Order | null;
|
||||||
|
equipment?: Equipment | null;
|
||||||
|
onEquipmentUsed?: (eq: Equipment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number) { return Math.round(n).toLocaleString('zh-CN'); }
|
||||||
|
function pct(n: number) { return `${(n * 100).toFixed(1)}%`; }
|
||||||
|
|
||||||
|
const GEN_STEPS = [
|
||||||
|
'读取订单工艺路线数据…',
|
||||||
|
'加载产线设备参数…',
|
||||||
|
'计算各工序不良率与损耗成本…',
|
||||||
|
'核算废品损失与返工成本…',
|
||||||
|
'对比设备方案损耗差异…',
|
||||||
|
'生成PMC排产决策报告…',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 损耗分析结果 ────────────────────────────────────────────────────────
|
||||||
|
interface StepLoss {
|
||||||
|
name: string;
|
||||||
|
cost: number;
|
||||||
|
defectRate: number;
|
||||||
|
lossCost: number; // 该工序损耗金额 = cost × defectRate
|
||||||
|
hasIssue: boolean;
|
||||||
|
issueDesc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineAnalysis {
|
||||||
|
line: ProductionLine;
|
||||||
|
color: string;
|
||||||
|
totalCost: number;
|
||||||
|
totalHours: number;
|
||||||
|
yieldRate: number;
|
||||||
|
totalLossCost: number; // 全流程损耗总金额
|
||||||
|
lossRate: number; // 损耗率 = 1 - 良率
|
||||||
|
issueCount: number; // 问题工序数
|
||||||
|
steps: StepLoss[];
|
||||||
|
perPieceCost: number; // 单件成本(含损耗分摊)
|
||||||
|
extraLossVsBest: number; // 相对最优方案的额外损耗
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyzeLine(line: ProductionLine, color: string, qty: number): LineAnalysis {
|
||||||
|
const steps: StepLoss[] = line.steps.map(s => ({
|
||||||
|
name: s.name,
|
||||||
|
cost: s.cost,
|
||||||
|
defectRate: s.defectRate,
|
||||||
|
lossCost: Math.round(s.cost * s.defectRate),
|
||||||
|
hasIssue: s.hasIssue,
|
||||||
|
issueDesc: s.issueDesc,
|
||||||
|
}));
|
||||||
|
const totalLossCost = steps.reduce((s, st) => s + st.lossCost, 0);
|
||||||
|
const totalCost = line.totalCost;
|
||||||
|
const perPieceCost = qty > 0 ? (totalCost + totalLossCost) / qty / line.yieldRate : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
line, color, totalCost, totalHours: line.totalHours,
|
||||||
|
yieldRate: line.yieldRate, totalLossCost,
|
||||||
|
lossRate: 1 - line.yieldRate,
|
||||||
|
issueCount: steps.filter(s => s.hasIssue).length,
|
||||||
|
steps, perPieceCost, extraLossVsBest: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CenterProductionSim({ order, equipment, onEquipmentUsed }: Props) {
|
||||||
|
const { tokens, theme } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [genStep, setGenStep] = useState(0);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [analyses, setAnalyses] = useState<LineAnalysis[]>([]);
|
||||||
|
const [triggerEquip, setTriggerEquip] = useState<Equipment | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const colors = ['#00cc88', '#ff8844', '#4499ff'];
|
||||||
|
|
||||||
|
const startGeneration = (_eq: Equipment) => {
|
||||||
|
setGenerating(true);
|
||||||
|
setGenStep(0);
|
||||||
|
setProgress(0);
|
||||||
|
setAnalyses([]);
|
||||||
|
let i = 0;
|
||||||
|
const tick = () => {
|
||||||
|
i++;
|
||||||
|
setGenStep(i);
|
||||||
|
setProgress(Math.min(100, Math.round((i / GEN_STEPS.length) * 100)));
|
||||||
|
if (i < GEN_STEPS.length) {
|
||||||
|
timerRef.current = setTimeout(tick, 280 + Math.random() * 120);
|
||||||
|
} else {
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setGenerating(false);
|
||||||
|
if (order) {
|
||||||
|
const lines = order.productionLines || [];
|
||||||
|
const qty = order.rows[0]?.qty ?? 1;
|
||||||
|
const results = lines.map((l, idx) => analyzeLine(l, colors[idx] || '#888', qty));
|
||||||
|
// 计算额外损耗(相对最优方案)
|
||||||
|
const minLoss = Math.min(...results.map(r => r.totalLossCost));
|
||||||
|
results.forEach(r => { r.extraLossVsBest = r.totalLossCost - minLoss; });
|
||||||
|
setAnalyses(results);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timerRef.current = setTimeout(tick, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (equipment && equipment.id !== triggerEquip?.id && !generating) {
|
||||||
|
setTriggerEquip(equipment);
|
||||||
|
startGeneration(equipment);
|
||||||
|
onEquipmentUsed?.(equipment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 无订单 ──
|
||||||
|
if (!order) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||||
|
<div style={{ width: 80, height: 80, borderRadius: 20, background: tokens.accentBg, border: `2px dashed ${tokens.borderAccent}44`, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 20, fontSize: 36 }}>📊</div>
|
||||||
|
<h3 style={{ color: tokens.text, fontSize: 18, fontWeight: 700, marginBottom: 8 }}>智能模拟实际成本</h3>
|
||||||
|
<p style={{ color: tokens.textMuted, fontSize: 13, textAlign: 'center', maxWidth: 400, lineHeight: 1.8 }}>
|
||||||
|
请先从左侧选择一个订单,<br/>然后从右侧拖拽设备卡片至此,AI 将自动分析不同设备对各工序损耗成本的影响。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qty = order.rows[0]?.qty ?? 0;
|
||||||
|
const revenue = order.rows[0]?.revenue ?? 0;
|
||||||
|
|
||||||
|
// ── 等待拖入设备 ──
|
||||||
|
if (!generating && analyses.length === 0 && !triggerEquip) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px 20px', minHeight: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 14 }}>📊</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: tokens.text }}>{order.customer}</span>
|
||||||
|
<span style={{ fontSize: 11, color: tokens.textMuted, padding: '2px 8px', borderRadius: 4, background: tokens.panelBgAlt }}>
|
||||||
|
{order.productCategory} · {qty.toLocaleString()} 件 · 收入 ¥{fmt(revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', border: `2px dashed ${tokens.borderAccent}33`, borderRadius: 12, margin: '0 20px' }}>
|
||||||
|
<div style={{ fontSize: 40, marginBottom: 12, opacity: 0.5 }}>⬇️</div>
|
||||||
|
<p style={{ color: tokens.textMuted, fontSize: 13 }}>从右侧「生产设备库」拖拽设备卡片到此区域</p>
|
||||||
|
<p style={{ color: `${tokens.textMuted}88`, fontSize: 11, marginTop: 4 }}>AI 将分析不同设备产线对各工序损耗成本的影响差异</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 测算动画 ──
|
||||||
|
if (generating) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||||
|
<div style={{ width: 52, height: 52, borderRadius: '50%', border: `3px solid ${tokens.accent}22`, borderTopColor: tokens.accent, animation: 'space-pulse 1s linear infinite', marginBottom: 16 }} />
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: tokens.accent, marginBottom: 6 }}>🔧 损耗成本测算中</div>
|
||||||
|
<div style={{ fontSize: 11, color: tokens.textMuted, marginBottom: 16 }}>正在分析各工序不良率与设备损耗差异…</div>
|
||||||
|
<div style={{ width: 280, height: 4, borderRadius: 2, background: tokens.border, overflow: 'hidden', marginBottom: 16 }}>
|
||||||
|
<div style={{ width: `${progress}%`, height: '100%', borderRadius: 2, background: `linear-gradient(to right, ${tokens.accent}, ${tokens.accent}cc)`, transition: 'width 0.3s ease' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 260 }}>
|
||||||
|
{GEN_STEPS.map((s, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
padding: '3px 0', fontSize: 11,
|
||||||
|
color: i < genStep ? tokens.success : i === genStep ? tokens.accent : `${tokens.textMuted}44`,
|
||||||
|
fontWeight: i === genStep ? 600 : 400, display: 'flex', gap: 6,
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 14 }}>{i < genStep ? '✓' : i === genStep ? '▸' : '○'}</span>{s}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PMC 损耗洞察报告 ──
|
||||||
|
const stepNames = analyses[0]?.steps.map(s => s.name) || [];
|
||||||
|
const maxLoss = Math.max(...analyses.map(a => a.totalLossCost));
|
||||||
|
const minLoss = Math.min(...analyses.map(a => a.totalLossCost));
|
||||||
|
const bestLine = analyses.reduce((best, a) => a.totalLossCost < best.totalLossCost ? a : best, analyses[0]);
|
||||||
|
const worstLine = analyses.reduce((worst, a) => a.totalLossCost > worst.totalLossCost ? a : worst, analyses[0]);
|
||||||
|
|
||||||
|
const cellBase = { padding: '6px 10px', fontSize: 10, borderBottom: `1px solid ${tokens.border}44`, fontFamily: 'monospace' as const };
|
||||||
|
const headCell = { ...cellBase, fontWeight: 700, color: tokens.textMuted, textAlign: 'center' as const, background: isLight ? tokens.tableHeaderBg : 'rgba(0,20,60,0.5)' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-scroll" style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '10px 14px', overflowY: 'auto', minHeight: 0, gap: 10 }}>
|
||||||
|
{/* ── 报告头部 ── */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 14 }}>📊</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: tokens.text }}>产线损耗成本洞察</span>
|
||||||
|
<span style={{ fontSize: 10, color: tokens.textMuted, padding: '2px 8px', borderRadius: 4, background: tokens.panelBgAlt, fontWeight: 500 }}>
|
||||||
|
{order.customer} · {order.productCategory} · {qty.toLocaleString()} 件
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setTriggerEquip(null); setAnalyses([]); }} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 6, border: `1px solid ${tokens.border}`,
|
||||||
|
background: 'transparent', color: tokens.textMuted, fontSize: 11, cursor: 'pointer',
|
||||||
|
}}>重新测算</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPI 对比卡片 ── */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
|
{analyses.map(a => {
|
||||||
|
const isBest = a.line.id === bestLine.line.id;
|
||||||
|
const isWorst = a.line.id === worstLine.line.id && analyses.length > 1;
|
||||||
|
return (
|
||||||
|
<div key={a.line.id} style={{
|
||||||
|
flex: 1, padding: '10px 12px', borderRadius: 10,
|
||||||
|
background: isLight ? (isBest ? '#f0fdf4' : '#fafbfd') : (isBest ? 'rgba(0,204,136,0.06)' : 'rgba(0,20,60,0.2)'),
|
||||||
|
border: `1.5px solid ${isBest ? a.color : isWorst ? tokens.danger + '44' : tokens.border}`,
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
{isBest && <div style={{ position: 'absolute', top: -8, right: 10, padding: '1px 8px', borderRadius: 8, fontSize: 9, fontWeight: 700, background: a.color, color: '#fff' }}>损耗最低</div>}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 6 }}>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: a.color }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: a.color }}>{a.line.equipment}({a.line.name})</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 8, color: tokens.textMuted }}>综合良率</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: a.yieldRate >= 0.95 ? tokens.success : a.yieldRate >= 0.90 ? tokens.warning : tokens.danger, fontFamily: 'monospace' }}>
|
||||||
|
{pct(a.yieldRate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 8, color: tokens.textMuted }}>损耗金额</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: a.totalLossCost === minLoss ? tokens.success : isWorst ? tokens.danger : tokens.warning, fontFamily: 'monospace' }}>
|
||||||
|
¥{fmt(a.totalLossCost)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 8, color: tokens.textMuted }}>问题工序</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: a.issueCount === 0 ? tokens.success : a.issueCount <= 1 ? tokens.warning : tokens.danger, fontFamily: 'monospace' }}>
|
||||||
|
{a.issueCount}/{a.steps.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 8, color: tokens.textMuted }}>额外损耗</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: a.extraLossVsBest === 0 ? tokens.success : tokens.danger, fontFamily: 'monospace' }}>
|
||||||
|
{a.extraLossVsBest === 0 ? '—' : `+¥${fmt(a.extraLossVsBest)}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 各工序损耗对比表(PMC核心) ── */}
|
||||||
|
<div style={{ borderRadius: 8, overflow: 'hidden', border: `1px solid ${tokens.border}`, flexShrink: 0 }}>
|
||||||
|
<div style={{ padding: '6px 12px', background: isLight ? 'rgba(30,60,130,0.04)' : 'rgba(0,20,60,0.3)', borderBottom: `1px solid ${tokens.border}`, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ width: 3, height: 12, borderRadius: 2, background: tokens.accent }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: tokens.text }}>各工序不良率与损耗成本对比</span>
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 10 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ ...headCell, textAlign: 'left', width: 60 }}>工序</th>
|
||||||
|
{analyses.map(a => (
|
||||||
|
<th key={a.line.id} colSpan={2} style={{ ...headCell, color: a.color, borderLeft: `1px solid ${tokens.border}` }}>
|
||||||
|
{a.line.equipment}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style={{ ...headCell, textAlign: 'left' }}></th>
|
||||||
|
{analyses.map(a => [
|
||||||
|
<th key={`${a.line.id}-rate`} style={{ ...headCell, fontWeight: 500, fontSize: 9, color: tokens.textMuted, borderLeft: `1px solid ${tokens.border}` }}>不良率</th>,
|
||||||
|
<th key={`${a.line.id}-loss`} style={{ ...headCell, fontWeight: 500, fontSize: 9, color: tokens.textMuted }}>损耗额</th>,
|
||||||
|
])}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stepNames.map((name, si) => {
|
||||||
|
// 该工序最高不良率(用于热力着色)
|
||||||
|
const maxDefect = Math.max(...analyses.map(a => a.steps[si]?.defectRate ?? 0));
|
||||||
|
return (
|
||||||
|
<tr key={name} style={{ background: si % 2 === 0 ? 'transparent' : (isLight ? 'rgba(0,40,120,0.015)' : 'rgba(0,20,60,0.08)') }}>
|
||||||
|
<td style={{ ...cellBase, textAlign: 'left', fontWeight: 600, color: tokens.text }}>{name}</td>
|
||||||
|
{analyses.map(a => {
|
||||||
|
const step = a.steps[si];
|
||||||
|
if (!step) return <td key={a.line.id} colSpan={2} style={cellBase}>—</td>;
|
||||||
|
const isHigh = step.defectRate >= 0.02;
|
||||||
|
const isMedium = step.defectRate >= 0.01;
|
||||||
|
const rateColor = step.defectRate === 0 ? tokens.textMuted : isHigh ? tokens.danger : isMedium ? tokens.warning : tokens.success;
|
||||||
|
// 热力条宽度
|
||||||
|
const heatPct = maxDefect > 0 ? (step.defectRate / maxDefect) * 100 : 0;
|
||||||
|
return [
|
||||||
|
<td key={`${a.line.id}-r`} style={{ ...cellBase, textAlign: 'center', borderLeft: `1px solid ${tokens.border}44`, position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: `${rateColor}33`, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${heatPct}%`, height: '100%', background: rateColor, opacity: 0.6 }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: rateColor, fontWeight: isHigh ? 700 : 500 }}>
|
||||||
|
{step.defectRate === 0 ? '—' : pct(step.defectRate)}
|
||||||
|
</span>
|
||||||
|
{step.hasIssue && <span style={{ marginLeft: 3, color: tokens.danger, fontSize: 8 }} title={step.issueDesc}>⚠</span>}
|
||||||
|
</td>,
|
||||||
|
<td key={`${a.line.id}-l`} style={{ ...cellBase, textAlign: 'right', color: step.lossCost > 500 ? tokens.danger : tokens.text, fontWeight: step.lossCost > 500 ? 700 : 500 }}>
|
||||||
|
{step.lossCost > 0 ? `¥${fmt(step.lossCost)}` : '—'}
|
||||||
|
</td>,
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* 合计行 */}
|
||||||
|
<tr style={{ background: isLight ? 'rgba(30,60,130,0.05)' : 'rgba(0,20,60,0.25)', fontWeight: 700 }}>
|
||||||
|
<td style={{ ...cellBase, textAlign: 'left', fontWeight: 700, color: tokens.text, borderTop: `1px solid ${tokens.border}` }}>合计</td>
|
||||||
|
{analyses.map(a => [
|
||||||
|
<td key={`${a.line.id}-tr`} style={{ ...cellBase, textAlign: 'center', fontWeight: 700, borderLeft: `1px solid ${tokens.border}44`, borderTop: `1px solid ${tokens.border}`, color: tokens.textMuted }}>
|
||||||
|
{pct(a.lossRate)}
|
||||||
|
</td>,
|
||||||
|
<td key={`${a.line.id}-tl`} style={{ ...cellBase, textAlign: 'right', fontWeight: 700, borderTop: `1px solid ${tokens.border}`, color: a.totalLossCost === minLoss ? tokens.success : tokens.danger }}>
|
||||||
|
¥{fmt(a.totalLossCost)}
|
||||||
|
</td>,
|
||||||
|
])}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 问题工序明细 ── */}
|
||||||
|
{analyses.some(a => a.steps.some(s => s.hasIssue)) && (
|
||||||
|
<div style={{ borderRadius: 8, overflow: 'hidden', border: `1px solid ${tokens.border}`, flexShrink: 0 }}>
|
||||||
|
<div style={{ padding: '6px 12px', background: isLight ? 'rgba(185,28,28,0.04)' : 'rgba(255,107,107,0.06)', borderBottom: `1px solid ${tokens.border}`, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 11, color: tokens.danger }}>⚠</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: tokens.text }}>问题工序明细</span>
|
||||||
|
<span style={{ fontSize: 9, color: tokens.textMuted }}>设备状态异常或工艺参数偏移</span>
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 10 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={headCell}>产线</th>
|
||||||
|
<th style={headCell}>工序</th>
|
||||||
|
<th style={headCell}>不良率</th>
|
||||||
|
<th style={{ ...headCell, textAlign: 'left' }}>问题描述</th>
|
||||||
|
<th style={headCell}>损耗额</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{analyses.flatMap(a =>
|
||||||
|
a.steps.filter(s => s.hasIssue).map(s => (
|
||||||
|
<tr key={`${a.line.id}-${s.name}`} style={{ background: isLight ? 'rgba(185,28,28,0.02)' : 'transparent' }}>
|
||||||
|
<td style={{ ...cellBase, textAlign: 'center' }}>
|
||||||
|
<span style={{ color: a.color, fontWeight: 600 }}>{a.line.equipment}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...cellBase, textAlign: 'center', fontWeight: 600, color: tokens.text }}>{s.name}</td>
|
||||||
|
<td style={{ ...cellBase, textAlign: 'center', color: tokens.danger, fontWeight: 700 }}>{pct(s.defectRate)}</td>
|
||||||
|
<td style={{ ...cellBase, textAlign: 'left', color: tokens.textMuted, fontSize: 9 }}>{s.issueDesc || '—'}</td>
|
||||||
|
<td style={{ ...cellBase, textAlign: 'right', color: tokens.danger, fontWeight: 600 }}>¥{fmt(s.lossCost)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 损耗金额柱状对比 ── */}
|
||||||
|
<div style={{ padding: '10px 14px', borderRadius: 8, background: isLight ? '#fafbfd' : 'rgba(0,20,60,0.15)', border: `1px solid ${tokens.border}`, flexShrink: 0 }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: tokens.textMuted, marginBottom: 8 }}>损耗总金额对比</div>
|
||||||
|
{analyses.map(a => {
|
||||||
|
const barPct = maxLoss > 0 ? (a.totalLossCost / maxLoss) * 100 : 0;
|
||||||
|
const isMin = a.totalLossCost === minLoss;
|
||||||
|
return (
|
||||||
|
<div key={a.line.id} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<span style={{ width: 60, fontSize: 10, color: a.color, fontWeight: 600, flexShrink: 0 }}>{a.line.equipment}</span>
|
||||||
|
<div style={{ flex: 1, height: 16, borderRadius: 4, background: tokens.border + '22', overflow: 'hidden', position: 'relative' }}>
|
||||||
|
<div style={{
|
||||||
|
width: `${barPct}%`, height: '100%', borderRadius: 4,
|
||||||
|
background: isMin ? `linear-gradient(to right, ${a.color}66, ${a.color})` : `${tokens.danger}55`,
|
||||||
|
transition: 'width 0.5s ease',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<span style={{ width: 72, textAlign: 'right', fontSize: 11, fontWeight: 700, color: isMin ? tokens.success : tokens.danger, fontFamily: 'monospace', flexShrink: 0 }}>
|
||||||
|
¥{fmt(a.totalLossCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── PMC 决策建议 ── */}
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px', borderRadius: 8,
|
||||||
|
background: `${bestLine.color}08`,
|
||||||
|
border: `1px solid ${bestLine.color}33`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 14 }}>💡</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: bestLine.color }}>PMC 排产建议</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 11, color: tokens.text, lineHeight: 1.8, margin: 0 }}>
|
||||||
|
<b style={{ color: bestLine.color }}>{bestLine.line.equipment}({bestLine.line.name})</b>综合良率最高({pct(bestLine.yieldRate)}),
|
||||||
|
全流程损耗金额仅 <b>¥{fmt(bestLine.totalLossCost)}</b>,{bestLine.issueCount === 0 ? '无问题工序' : `仅 ${bestLine.issueCount} 个问题工序`}。
|
||||||
|
相比{worstLine.line.equipment}方案,<b style={{ color: tokens.danger }}>减少损耗 ¥{fmt(worstLine.totalLossCost - bestLine.totalLossCost)}</b>
|
||||||
|
(良率提升 {pct(bestLine.yieldRate - worstLine.yieldRate)})。
|
||||||
|
</p>
|
||||||
|
{analyses.length > 1 && worstLine.issueCount > 2 && (
|
||||||
|
<p style={{ fontSize: 11, color: tokens.danger, lineHeight: 1.8, margin: '6px 0 0', fontWeight: 600 }}>
|
||||||
|
⚠ {worstLine.line.equipment}存在 {worstLine.issueCount} 个问题工序({worstLine.steps.filter(s => s.hasIssue).map(s => s.name).join('、')}),
|
||||||
|
建议安排设备维保后再排产,或优先使用{bestLine.line.equipment}以降低损耗风险。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
import { type Order, type CostRow } from '../data/orders';
|
||||||
|
import type { DragEvent } from 'react';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import ProductionLineComparison from './ProductionLineComparison';
|
||||||
|
|
||||||
|
interface CenterProfitTableProps {
|
||||||
|
order: Order | null;
|
||||||
|
resetKey?: number;
|
||||||
|
onDrop?: (event: DragEvent<HTMLDivElement>) => void;
|
||||||
|
isProcessing?: boolean;
|
||||||
|
processingOrder?: Order | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number, decimals = 0) {
|
||||||
|
if (decimals > 0) return n.toLocaleString('zh-CN', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||||
|
return Math.round(n).toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(n: number) {
|
||||||
|
const sign = n > 0 ? '+' : '';
|
||||||
|
return `${sign}${(n * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorPct(n: number, textSub: string, danger: string, success: string) {
|
||||||
|
if (n > 0.01) return danger;
|
||||||
|
if (n < -0.005) return success;
|
||||||
|
return textSub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorMargin(n: number, success: string, warning: string, danger: string) {
|
||||||
|
if (n >= 0.15) return success;
|
||||||
|
if (n >= 0.05) return warning;
|
||||||
|
return danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 空状态工作区 ─────────────────────────────────────────────────────────────
|
||||||
|
function EmptyState({ onDrop }: { onDrop?: (event: DragEvent<HTMLDivElement>) => void }) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
// 浅色模式三种成本类型统一用蓝色调,用深浅区分
|
||||||
|
const costColors = isLight
|
||||||
|
? ['#1e40af', '#2563eb', '#3b82f6']
|
||||||
|
: ['#7b61ff', '#00d4ff', '#00e599'];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={e => { e.preventDefault(); e.currentTarget.style.background = tokens.accentBg; }}
|
||||||
|
onDragLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
onDrop={e => { e.currentTarget.style.background = 'transparent'; onDrop?.(e); }}
|
||||||
|
style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 20, padding: 40, transition: 'background 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 订单盈利能力分析表图示 */}
|
||||||
|
<div style={{
|
||||||
|
width: 280, border: `1px solid ${tokens.border}`,
|
||||||
|
borderRadius: 12, padding: '16px 20px',
|
||||||
|
background: tokens.panelBg,
|
||||||
|
}}>
|
||||||
|
<div style={{ color: tokens.textSub, fontSize: 15, fontWeight: 700, textAlign: 'center', marginBottom: 10 }}>订单盈利能力分析表</div>
|
||||||
|
{['\u6807\u51c6\u6210\u672c', '\u91c7\u8d2d\u6210\u672c', '\u9886\u7528\u6210\u672c'].map((type, i) => {
|
||||||
|
const colors = costColors;
|
||||||
|
return (
|
||||||
|
<div key={type} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: colors[i], flexShrink: 0 }} />
|
||||||
|
<span style={{ color: tokens.textMuted, fontSize: 14 }}>{type}</span>
|
||||||
|
<div style={{ flex: 1, height: 4, borderRadius: 2, background: `${colors[i]}22`, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${[60, 75, 80][i]}%`, height: '100%', background: colors[i], borderRadius: 2, opacity: 0.6 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ marginTop: 8, paddingTop: 8, borderTop: `1px solid ${tokens.border}`, color: tokens.textMuted, fontSize: 13, textAlign: 'center' }}>
|
||||||
|
含成本偏差分析 · 报价对比
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ color: tokens.textSub, fontSize: 20, fontWeight: 700, marginBottom: 8 }}>
|
||||||
|
订单盈利能力分析
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 16, lineHeight: 1.7, marginBottom: 12 }}>
|
||||||
|
拖拽左侧订单卡片到此处<br />
|
||||||
|
生成订单盈利能力分析表
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.accent, fontSize: 15, padding: '6px 14px', borderRadius: 20, border: `1px dashed ${tokens.accent}55`, display: 'inline-block' }}>
|
||||||
|
或点击左侧订单列表查看详情
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 成本子项拆分(基于理论成本比例) ────────────────────────────────────────────
|
||||||
|
function getCostSubItems(row: CostRow) {
|
||||||
|
const dm = row.directMaterial;
|
||||||
|
const dl = row.directLabor;
|
||||||
|
const oh = row.manufacturingCost;
|
||||||
|
const rawMaterial = Math.round(dm * (33 / 35));
|
||||||
|
const freight = dm - rawMaterial;
|
||||||
|
const directLabor = Math.round(dl * (20 / 30));
|
||||||
|
const indirectLabor = dl - directLabor;
|
||||||
|
const variableMgmt = Math.round(oh * (15 / 35));
|
||||||
|
const fixedMgmt = oh - variableMgmt;
|
||||||
|
const rent = Math.round(fixedMgmt * (3 / 20));
|
||||||
|
const utilities = fixedMgmt - rent;
|
||||||
|
return {
|
||||||
|
rawMaterialCost: dm, rawMaterial, freight,
|
||||||
|
laborCost: dl, directLabor, indirectLabor,
|
||||||
|
mgmtCost: oh, variableMgmt, fixedMgmt, rent, utilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 主体分析表 ────────────────────────────────────────────────────────────────
|
||||||
|
function ProfitTable({ order }: { order: Order }) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const costColors = isLight
|
||||||
|
? ['#1e40af', '#2563eb', '#3b82f6'] // 浅色:标准=原材料,人工=直接人工,管理=管理成本
|
||||||
|
: ['#7b61ff', '#00d4ff', '#00e599'];
|
||||||
|
const stdRow = order.rows.find(r => r.type === '原材料成本')!;
|
||||||
|
const purRow = order.rows.find(r => r.type === '直接人工成本')!;
|
||||||
|
const useRow = order.rows.find(r => r.type === '管理成本')!;
|
||||||
|
|
||||||
|
const headerCell = (text: string, sub?: string, colspan?: number, bg?: string, rowSpan?: number) => {
|
||||||
|
// 浅色模式下第一级表头(大组)用深层蓝色系,第二级用浅蓝色系
|
||||||
|
const isSectionHeader = !!bg;
|
||||||
|
return (
|
||||||
|
<th colSpan={colspan} rowSpan={rowSpan} style={{
|
||||||
|
padding: isSectionHeader ? '8px 10px' : '6px 8px',
|
||||||
|
fontSize: isSectionHeader ? 12 : 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: isLight
|
||||||
|
? (isSectionHeader ? 'transparent' : 'rgba(30,60,130,0.05)')
|
||||||
|
: (bg || tokens.tableHeaderBg),
|
||||||
|
color: isLight
|
||||||
|
? (isSectionHeader ? 'rgba(255,255,255,0.95)' : tokens.textSub)
|
||||||
|
: (isSectionHeader ? '#ffffff' : 'rgba(255,255,255,0.92)'),
|
||||||
|
borderBottom: isLight
|
||||||
|
? (isSectionHeader ? '1px solid rgba(255,255,255,0.15)' : `2px solid ${tokens.accent}30`)
|
||||||
|
: `1px solid ${tokens.border}`,
|
||||||
|
borderRight: `1px solid ${isLight ? (isSectionHeader ? 'rgba(255,255,255,0.12)' : 'rgba(30,60,130,0.1)') : tokens.borderSub}`,
|
||||||
|
borderLeft: `1px solid ${isLight ? (isSectionHeader ? 'rgba(255,255,255,0.12)' : 'rgba(30,60,130,0.1)') : tokens.borderSub}`,
|
||||||
|
borderTop: `1px solid ${isLight ? (isSectionHeader ? 'rgba(255,255,255,0.1)' : 'rgba(30,60,130,0.1)') : tokens.borderSub}`,
|
||||||
|
textAlign: 'center', whiteSpace: 'nowrap',
|
||||||
|
letterSpacing: isSectionHeader ? '0.06em' : '0',
|
||||||
|
}}>
|
||||||
|
{text}{sub && <div style={{ fontSize: 10, fontWeight: 400, color: isLight && isSectionHeader ? 'rgba(255,255,255,0.75)' : (!isLight ? 'rgba(255,255,255,0.6)' : tokens.textSub), marginTop: 1 }}>{sub}</div>}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataCell = (val: string | number, color?: string, align: 'left' | 'center' | 'right' = 'right', bold?: boolean, isKey?: boolean) => (
|
||||||
|
<td style={{
|
||||||
|
padding: '7px 10px', fontSize: 12,
|
||||||
|
color: color || tokens.text,
|
||||||
|
fontWeight: bold ? 700 : 400,
|
||||||
|
textAlign: align,
|
||||||
|
borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderLeft: `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderTop: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
background: isKey && isLight ? 'rgba(26,86,219,0.06)' : undefined,
|
||||||
|
}}>{val}</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 成本类型圆形标识点
|
||||||
|
const costDot = (color: string) => (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: color, marginRight: 6, flexShrink: 0,
|
||||||
|
boxShadow: isLight ? 'none' : `0 0 4px ${color}80`,
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRow = (row: typeof stdRow, color: string, isHighlight?: boolean) => {
|
||||||
|
const si = getCostSubItems(row);
|
||||||
|
const subItems = [
|
||||||
|
{ val: si.rawMaterialCost, bold: true },
|
||||||
|
{ val: si.rawMaterial },
|
||||||
|
{ val: si.freight },
|
||||||
|
{ val: si.laborCost, bold: true },
|
||||||
|
{ val: si.directLabor },
|
||||||
|
{ val: si.indirectLabor },
|
||||||
|
{ val: si.mgmtCost, bold: true },
|
||||||
|
{ val: si.variableMgmt },
|
||||||
|
{ val: si.fixedMgmt, bold: true },
|
||||||
|
{ val: si.rent },
|
||||||
|
{ val: si.utilities },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<tr style={{
|
||||||
|
background: isLight
|
||||||
|
? (isHighlight ? 'rgba(26,86,219,0.04)' : '#ffffff')
|
||||||
|
: (isHighlight ? `${color}08` : 'transparent'),
|
||||||
|
transition: 'background 0.15s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = isLight ? 'rgba(26,86,219,0.07)' : tokens.tableRowHover; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = isLight ? (isHighlight ? 'rgba(26,86,219,0.04)' : '#ffffff') : (isHighlight ? `${color}08` : 'transparent'); }}
|
||||||
|
>
|
||||||
|
<td style={{
|
||||||
|
padding: '7px 10px',
|
||||||
|
borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderLeft: isLight ? `3px solid ${color}` : `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`,
|
||||||
|
borderTop: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
||||||
|
{!isLight && costDot(color)}
|
||||||
|
<span style={{ color, fontSize: 12, fontWeight: 700 }}>{row.type}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{dataCell(fmt(row.qty))}
|
||||||
|
{dataCell(fmt(row.unitPrice, 4))}
|
||||||
|
{dataCell(fmt(row.revenue), tokens.textSub, 'right', true, true)}
|
||||||
|
{subItems.map((item) => dataCell(fmt(item.val), item.bold ? tokens.text : undefined, 'right', !!item.bold))}
|
||||||
|
{dataCell(fmt(row.grossProfit), row.grossProfit >= 0 ? tokens.success : tokens.danger, 'right', true)}
|
||||||
|
{dataCell(`${(row.grossMargin * 100).toFixed(2)}%`, colorMargin(row.grossMargin, tokens.success, tokens.warning, tokens.danger), 'right', true)}
|
||||||
|
{dataCell(fmt(row.mgmtExpense, 2))}
|
||||||
|
{dataCell(fmt(row.netProfit), row.netProfit >= 0 ? tokens.success : tokens.danger, 'right', true)}
|
||||||
|
{dataCell(`${(row.netMargin * 100).toFixed(0)}%`, colorMargin(row.netMargin, tokens.success, tokens.warning, tokens.danger), 'right', true)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 偏差行
|
||||||
|
const deviationRow = (label: string, aRow: typeof stdRow, bRow: typeof stdRow, color: string) => {
|
||||||
|
const aSi = getCostSubItems(aRow);
|
||||||
|
const bSi = getCostSubItems(bRow);
|
||||||
|
const subKeys = Object.keys(aSi) as (keyof typeof aSi)[];
|
||||||
|
const borderStyle = `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`;
|
||||||
|
return (
|
||||||
|
<tr style={{
|
||||||
|
background: isLight ? 'rgba(245,248,255,0.8)' : 'transparent',
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}}>
|
||||||
|
<td colSpan={4} style={{
|
||||||
|
padding: '4px 10px 4px 16px',
|
||||||
|
fontSize: 11, color: isLight ? tokens.textSub : tokens.textMuted,
|
||||||
|
borderBottom: borderStyle, borderRight: borderStyle,
|
||||||
|
borderLeft: isLight ? `3px solid ${color}40` : borderStyle,
|
||||||
|
borderTop: 'none', textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
<span style={{ color, fontWeight: 700, marginRight: 4 }}>▲</span>{label}
|
||||||
|
</td>
|
||||||
|
{subKeys.map((k, i) => {
|
||||||
|
const diff = aSi[k] - bSi[k];
|
||||||
|
const c = diff < 0 ? tokens.success : diff > 0 ? tokens.danger : tokens.textMuted;
|
||||||
|
return (
|
||||||
|
<td key={i} style={{
|
||||||
|
padding: '4px 10px', fontSize: 11, color: c, fontStyle: 'italic',
|
||||||
|
textAlign: 'right', fontWeight: 600,
|
||||||
|
borderBottom: borderStyle, borderRight: borderStyle, borderTop: 'none', borderLeft: i === 0 ? borderStyle : 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{`${diff >= 0 ? '+' : ''}${fmt(diff)}`}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td colSpan={6} style={{ borderBottom: borderStyle, borderRight: borderStyle, borderTop: 'none', borderLeft: 'none' }} />
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 16px' }} className="space-scroll">
|
||||||
|
{/* 订单基本信息 */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 800, color: tokens.text, marginBottom: 4, textAlign: 'center', letterSpacing: '0.06em' }}>
|
||||||
|
订单盈利能力分析表
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: tokens.textMuted, textAlign: 'left', marginBottom: 8 }}>
|
||||||
|
单位:元 币种:人民币
|
||||||
|
</div>
|
||||||
|
{/* 基本信息格 */}
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12,
|
||||||
|
border: isLight ? '1px solid rgba(30,60,130,0.12)' : `1px solid ${tokens.border}`,
|
||||||
|
borderRadius: 6, overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, width: '10%', whiteSpace: 'nowrap' }}>制造令号</td>
|
||||||
|
<td style={{ padding: '6px 10px', color: tokens.accent, fontWeight: 600, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, width: '20%', fontFamily: 'monospace' }}>{order.orderNo}</td>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, width: '8%' }}>客户</td>
|
||||||
|
<td style={{ padding: '6px 10px', color: tokens.text, fontWeight: 600, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, width: '14%' }}>{order.customer}</td>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, width: '8%' }}>业务员</td>
|
||||||
|
<td style={{ padding: '6px 10px', color: tokens.text, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, width: '14%' }}>{order.salesperson}</td>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`, width: '8%' }}>渠道</td>
|
||||||
|
<td style={{ padding: '6px 10px', color: isLight ? tokens.textSub : tokens.textMuted, fontWeight: 500, borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`, width: '14%' }}>{order.channel}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}` }}>结单日期</td>
|
||||||
|
<td style={{ padding: '6px 10px', color: tokens.text, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}` }}>{order.closedDate || '未结单'}</td>
|
||||||
|
<td style={{ padding: '6px 10px', background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, color: tokens.textSub, fontWeight: 700, borderRight: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}` }}>品类</td>
|
||||||
|
<td colSpan={5} style={{ padding: '6px 10px', color: tokens.text }}>{order.productCategory}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 一、总体情况 */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: tokens.textSub, marginBottom: 6 }}>一、总体情况</div>
|
||||||
|
<div style={{ overflowX: 'auto', overflowY: 'hidden', borderRadius: 8, border: isLight ? '1px solid rgba(30,60,130,0.12)' : `1px solid ${tokens.border}`, boxShadow: isLight ? '0 1px 6px rgba(30,60,130,0.06)' : 'none' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 1500 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(22,56,140,0.88)' : 'transparent' }}>
|
||||||
|
{headerCell('项目', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('订单信息', undefined, 3, 'rgba(0,30,90,0.7)')}
|
||||||
|
{headerCell('制造成本信息', undefined, 12, 'rgba(0,30,90,0.7)')}
|
||||||
|
{headerCell('毛利情况', undefined, 2, 'rgba(0,40,100,0.6)')}
|
||||||
|
{headerCell('管销研', undefined, 1, 'rgba(0,30,90,0.7)')}
|
||||||
|
{headerCell('利润情况', undefined, 2, 'rgba(0,40,100,0.6)')}
|
||||||
|
</tr>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(22,56,140,0.72)' : 'rgba(0,20,60,0.4)' }}>
|
||||||
|
{headerCell('分类', undefined, undefined, undefined, 2)}
|
||||||
|
{headerCell('发货数量', undefined, undefined, undefined, 2)}
|
||||||
|
{headerCell('单价', undefined, undefined, undefined, 2)}
|
||||||
|
{headerCell('销售收入', '(含税)', undefined, undefined, 2)}
|
||||||
|
{headerCell('原材料成本', undefined, 2, isLight ? 'rgba(30,60,130,0.1)' : 'rgba(0,40,100,0.3)')}
|
||||||
|
{headerCell('人工成本', undefined, 2, isLight ? 'rgba(30,60,130,0.1)' : 'rgba(0,40,100,0.3)')}
|
||||||
|
{headerCell('管理成本', undefined, 7, isLight ? 'rgba(30,60,130,0.1)' : 'rgba(0,40,100,0.3)')}
|
||||||
|
{headerCell('毛利', undefined, undefined, undefined, 2)}
|
||||||
|
{headerCell('毛利率', undefined, undefined, undefined, 2)}
|
||||||
|
{headerCell('管理费用', undefined, undefined, undefined, 2)}
|
||||||
|
{headerCell('净利润', undefined, undefined, undefined, 2)}
|
||||||
|
{headerCell('净利率', undefined, undefined, undefined, 2)}
|
||||||
|
</tr>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(30,60,130,0.06)' : 'transparent' }}>
|
||||||
|
{headerCell('原材料成本')}
|
||||||
|
{headerCell('原材料')}
|
||||||
|
{headerCell('运费')}
|
||||||
|
{headerCell('人工成本')}
|
||||||
|
{headerCell('直接人工成本')}
|
||||||
|
{headerCell('间接人工成本')}
|
||||||
|
{headerCell('管理成本')}
|
||||||
|
{headerCell('变动管理成本')}
|
||||||
|
{headerCell('固定管理成本', undefined, 2, isLight ? 'rgba(30,60,130,0.08)' : 'rgba(0,30,80,0.25)')}
|
||||||
|
{headerCell('房租')}
|
||||||
|
{headerCell('办公室水电')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody style={{ background: isLight ? '#ffffff' : 'transparent' }}>
|
||||||
|
{renderRow(stdRow, costColors[0])}
|
||||||
|
{deviationRow('直接人工成本 vs 原材料成本', purRow, stdRow, costColors[1])}
|
||||||
|
{renderRow(purRow, costColors[1])}
|
||||||
|
{deviationRow('管理成本 vs 原材料成本', useRow, stdRow, costColors[2])}
|
||||||
|
{renderRow(useRow, costColors[2], true)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 附:成本情况明细 */}
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: tokens.textSub, marginBottom: 6 }}>附:成本情况</div>
|
||||||
|
<div style={{ overflowX: 'auto', overflowY: 'hidden', borderRadius: 8, border: isLight ? '1px solid rgba(30,60,130,0.12)' : `1px solid ${tokens.border}`, boxShadow: isLight ? '0 1px 6px rgba(30,60,130,0.06)' : 'none' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 800 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(22,56,140,0.88)' : 'transparent' }}>
|
||||||
|
{headerCell('项目', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('成本明细', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('原材料成本', undefined, 2, isLight ? 'rgba(0,20,80,0.85)' : 'rgba(123,97,255,0.18)')}
|
||||||
|
{headerCell('直接人工成本', undefined, 2, isLight ? 'rgba(0,20,80,0.85)' : 'rgba(0,212,255,0.14)')}
|
||||||
|
{headerCell('管理成本', undefined, 2, isLight ? 'rgba(0,20,80,0.85)' : 'rgba(0,229,153,0.14)')}
|
||||||
|
{headerCell('直接人工成本vs原材料成本差异', undefined, 2, isLight ? 'rgba(0,20,80,0.85)' : 'rgba(255,179,64,0.12)')}
|
||||||
|
{headerCell('管理成本与原材料成本差异', undefined, 2, isLight ? 'rgba(0,20,80,0.85)' : 'rgba(255,107,107,0.12)')}
|
||||||
|
</tr>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(30,60,130,0.06)' : 'transparent' }}>
|
||||||
|
{headerCell('')}
|
||||||
|
{headerCell('')}
|
||||||
|
{headerCell('金额')}
|
||||||
|
{headerCell('占销售比')}
|
||||||
|
{headerCell('金额')}
|
||||||
|
{headerCell('占销售比')}
|
||||||
|
{headerCell('金额')}
|
||||||
|
{headerCell('占销售比')}
|
||||||
|
{headerCell('金额')}
|
||||||
|
{headerCell('占销售比')}
|
||||||
|
{headerCell('金额')}
|
||||||
|
{headerCell('占销售比')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{order.costBreakdown.map((group) => {
|
||||||
|
const rows = group.items;
|
||||||
|
const bStyle = `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`;
|
||||||
|
const bStyleHeader = `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`;
|
||||||
|
return rows.map((item, itemIdx) => {
|
||||||
|
const pAmt = item.purchaseVsStdAmt;
|
||||||
|
const uAmt = item.usageVsStdAmt;
|
||||||
|
return (
|
||||||
|
<tr key={`${group.category}-${item.label}`}
|
||||||
|
style={{ background: isLight ? (itemIdx % 2 === 0 ? 'rgba(241,245,255,0.6)' : '#ffffff') : (itemIdx % 2 === 0 ? tokens.tableRowAlt : 'transparent') }}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = isLight ? 'rgba(26,86,219,0.07)' : tokens.tableRowHover; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = isLight ? (itemIdx % 2 === 0 ? 'rgba(241,245,255,0.6)' : '#ffffff') : (itemIdx % 2 === 0 ? tokens.tableRowAlt : 'transparent'); }}
|
||||||
|
>
|
||||||
|
{itemIdx === 0 && (
|
||||||
|
<td rowSpan={rows.length} style={{
|
||||||
|
padding: '6px 10px', fontSize: 12, fontWeight: 700,
|
||||||
|
color: tokens.textSub, textAlign: 'center',
|
||||||
|
border: bStyleHeader,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
background: isLight ? 'rgba(30,60,130,0.05)' : tokens.tableHeaderBg,
|
||||||
|
}}>
|
||||||
|
{group.category}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? tokens.textSub : tokens.textMuted, border: bStyle, textAlign: 'center' }}>
|
||||||
|
{item.label}
|
||||||
|
</td>
|
||||||
|
{/* 原材料成本 */}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[0] : tokens.accent, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap' }}>
|
||||||
|
{fmt(item.stdCost, 2)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[0] : tokens.accent, textAlign: 'right', border: bStyle }}>
|
||||||
|
{item.stdPct > 0 ? `${(item.stdPct * 100).toFixed(2)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
{/* 直接人工成本 */}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[1] : tokens.textSub, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap' }}>
|
||||||
|
{fmt(item.purchaseCost, 2)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[1] : tokens.textSub, textAlign: 'right', border: bStyle }}>
|
||||||
|
{item.purchasePct > 0 ? `${(item.purchasePct * 100).toFixed(2)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
{/* 管理成本 */}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[2] : tokens.success, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap' }}>
|
||||||
|
{fmt(item.usageCost, 2)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: isLight ? costColors[2] : tokens.success, textAlign: 'right', border: bStyle }}>
|
||||||
|
{item.usagePct > 0 ? `${(item.usagePct * 100).toFixed(2)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
{/* 直接人工成本 vs 标准差异 */}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: colorPct(item.purchaseVsStdPct, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: Math.abs(pAmt) > 2000 ? 700 : 400 }}>
|
||||||
|
{pAmt !== 0 ? `${pAmt >= 0 ? '+' : ''}${fmt(pAmt, 2)}` : '0'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: colorPct(item.purchaseVsStdPct, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, fontWeight: Math.abs(item.purchaseVsStdPct) > 0.02 ? 700 : 400 }}>
|
||||||
|
{item.purchaseVsStdPct !== 0 ? pct(item.purchaseVsStdPct) : '0.00%'}
|
||||||
|
</td>
|
||||||
|
{/* 领用 vs 标准差异 */}
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: colorPct(item.usageVsStdPct, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: Math.abs(uAmt) > 2000 ? 700 : 400 }}>
|
||||||
|
{uAmt !== 0 ? `${uAmt >= 0 ? '+' : ''}${fmt(uAmt, 2)}` : '0'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontSize: 12, color: colorPct(item.usageVsStdPct, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, fontWeight: Math.abs(item.usageVsStdPct) > 0.02 ? 700 : 400 }}>
|
||||||
|
{item.usageVsStdPct !== 0 ? pct(item.usageVsStdPct) : '0.00%'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
{/* 合计行 */}
|
||||||
|
{(() => {
|
||||||
|
const sumStd = order.costBreakdown.reduce((s, g) => s + g.items.reduce((ss, i) => ss + i.stdCost, 0), 0);
|
||||||
|
const sumPur = order.costBreakdown.reduce((s, g) => s + g.items.reduce((ss, i) => ss + i.purchaseCost, 0), 0);
|
||||||
|
const sumUse = order.costBreakdown.reduce((s, g) => s + g.items.reduce((ss, i) => ss + i.usageCost, 0), 0);
|
||||||
|
const pVsS = sumPur - sumStd;
|
||||||
|
const uVsS = sumUse - sumStd;
|
||||||
|
const rev = stdRow.revenue;
|
||||||
|
const bStyle = `1px solid ${isLight ? 'rgba(30,60,130,0.07)' : tokens.borderSub}`;
|
||||||
|
const bStyleH = `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.border}`;
|
||||||
|
return (
|
||||||
|
<tr style={{ background: isLight ? 'rgba(30,60,130,0.06)' : tokens.tableHeaderBg, fontWeight: 700 }}>
|
||||||
|
<td colSpan={2} style={{ padding: '7px 10px', fontSize: 12, color: tokens.textSub, textAlign: 'center', border: bStyleH, fontWeight: 700 }}>合计</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[0] : tokens.accent, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: 700 }}>{fmt(sumStd, 2)}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[0] : tokens.accent, textAlign: 'right', border: bStyle, fontWeight: 700 }}>{rev > 0 ? `${(sumStd / rev * 100).toFixed(2)}%` : '—'}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[1] : tokens.textSub, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: 700 }}>{fmt(sumPur, 2)}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[1] : tokens.textSub, textAlign: 'right', border: bStyle, fontWeight: 700 }}>{rev > 0 ? `${(sumPur / rev * 100).toFixed(2)}%` : '—'}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[2] : tokens.success, textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: 700 }}>{fmt(sumUse, 2)}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: isLight ? costColors[2] : tokens.success, textAlign: 'right', border: bStyle, fontWeight: 700 }}>{rev > 0 ? `${(sumUse / rev * 100).toFixed(2)}%` : '—'}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: colorPct(pVsS / sumStd, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: 700 }}>{`${pVsS >= 0 ? '+' : ''}${fmt(pVsS, 2)}`}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: colorPct(pVsS / sumStd, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, fontWeight: 700 }}>{pct(pVsS / sumStd)}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: colorPct(uVsS / sumStd, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, whiteSpace: 'nowrap', fontWeight: 700 }}>{`${uVsS >= 0 ? '+' : ''}${fmt(uVsS, 2)}`}</td>
|
||||||
|
<td style={{ padding: '7px 10px', fontSize: 12, color: colorPct(uVsS / sumStd, tokens.textSub, tokens.danger, tokens.success), textAlign: 'right', border: bStyle, fontWeight: 700 }}>{pct(uVsS / sumStd)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 报价成本对比(如果有数据) */}
|
||||||
|
{order.quoteCostItems && order.quoteCostItems.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: tokens.textSub, marginBottom: 6 }}>二、与客户报价成本细项对比</div>
|
||||||
|
<div style={{ overflowX: 'auto', overflowY: 'hidden', borderRadius: 8, border: isLight ? '1px solid rgba(30,60,130,0.12)' : `1px solid ${tokens.border}`, boxShadow: isLight ? '0 1px 6px rgba(30,60,130,0.06)' : 'none' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 600 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: isLight ? 'rgba(22,56,140,0.88)' : 'transparent' }}>
|
||||||
|
{headerCell('成本细项', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('报价单价', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('数量', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('报价总成本', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('占报价比', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('原材料成本', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('直接人工成本', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('管理成本', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
{headerCell('报价 vs 领用差异', undefined, 1, 'rgba(0,20,80,0.85)')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{order.quoteCostItems.map((qi, i) => {
|
||||||
|
const stdItem = order.costBreakdown.flatMap(g => g.items).find(it => it.label.includes(qi.name.slice(0, 2)));
|
||||||
|
const useAmt = stdItem?.usageCost ?? 0;
|
||||||
|
const diff = qi.totalCost - useAmt;
|
||||||
|
const quoteColor = isLight ? '#1e3a6e' : '#ffb340';
|
||||||
|
const quoteValColor = isLight ? '#111827' : undefined;
|
||||||
|
return (
|
||||||
|
<tr key={qi.name} style={{ background: isLight ? (i % 2 === 0 ? 'rgba(241,245,255,0.6)' : '#ffffff') : (i % 2 === 0 ? tokens.tableRowAlt : 'transparent') }}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = isLight ? 'rgba(26,86,219,0.07)' : tokens.tableRowHover; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = isLight ? (i % 2 === 0 ? 'rgba(241,245,255,0.6)' : '#ffffff') : (i % 2 === 0 ? tokens.tableRowAlt : 'transparent'); }}
|
||||||
|
>
|
||||||
|
{dataCell(qi.name, quoteColor, 'center', true, true)}
|
||||||
|
{dataCell(fmt(qi.quotedPrice, 2), quoteValColor, 'right')}
|
||||||
|
{dataCell(fmt(qi.quantity), quoteValColor, 'right')}
|
||||||
|
{dataCell(fmt(qi.totalCost, 2), quoteColor, 'right', true)}
|
||||||
|
{dataCell(`${(qi.percentage * 100).toFixed(2)}%`, quoteColor, 'right', true)}
|
||||||
|
{dataCell(stdItem ? fmt(stdItem.stdCost, 2) : '—', isLight ? costColors[0] : tokens.accent, 'right')}
|
||||||
|
{dataCell(stdItem ? fmt(stdItem.purchaseCost, 2) : '—', isLight ? costColors[1] : tokens.textSub, 'right')}
|
||||||
|
{dataCell(stdItem ? fmt(stdItem.usageCost, 2) : '—', isLight ? costColors[2] : tokens.success, 'right')}
|
||||||
|
{dataCell(useAmt > 0 ? `${diff >= 0 ? '+' : ''}${fmt(diff, 2)}` : '—',
|
||||||
|
useAmt > 0 ? (diff >= 0 ? tokens.success : tokens.danger) : undefined, 'right', true)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 备注 */}
|
||||||
|
{order.note && (
|
||||||
|
<div style={{ padding: '12px 14px', borderRadius: 10, background: isLight ? 'rgba(180,83,9,0.06)' : tokens.warningBg, border: `1px solid ${isLight ? 'rgba(180,83,9,0.18)' : tokens.warning + '33'}`, marginBottom: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke={isLight ? '#92400e' : tokens.warning} strokeWidth="1.3"/><path d="M7 4 L7 8" stroke={isLight ? '#92400e' : tokens.warning} strokeWidth="1.4" strokeLinecap="round"/><circle cx="7" cy="10.2" r="0.8" fill={isLight ? '#92400e' : tokens.warning}/></svg>
|
||||||
|
<span style={{ color: isLight ? '#92400e' : tokens.warning, fontSize: 12, fontWeight: 700 }}>AI 分析备注</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: isLight ? '#374151' : tokens.text, fontSize: 12, lineHeight: 1.7 }}>{order.note}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 生产线成本对比 */}
|
||||||
|
{order.productionLines && order.productionLines.length > 0 && (
|
||||||
|
<ProductionLineComparison lines={order.productionLines} order={order} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 思考计算动画 ──────────────────────────────────────────────────────────────────
|
||||||
|
function ProcessingAnimation({ order }: { order: Order }) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const costColors = isLight
|
||||||
|
? ['#1e40af', '#2563eb', '#3b82f6']
|
||||||
|
: ['#7b61ff', '#00d4ff', '#00e599'];
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 28, padding: 40,
|
||||||
|
}}>
|
||||||
|
{/* 旋转卡片 */}
|
||||||
|
<div style={{ perspective: '1000px', width: 260, height: 160 }}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
borderRadius: 16,
|
||||||
|
border: `1px solid ${isLight ? 'rgba(30,60,130,0.15)' : tokens.borderAccent}`,
|
||||||
|
background: isLight
|
||||||
|
? 'linear-gradient(145deg, #ffffff 0%, rgba(235,242,255,0.95) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(0,30,80,0.8) 0%, rgba(0,15,50,0.9) 100%)',
|
||||||
|
boxShadow: isLight
|
||||||
|
? '0 8px 32px rgba(30,60,130,0.12), 0 2px 8px rgba(30,60,130,0.07), inset 0 1px 0 rgba(255,255,255,1)'
|
||||||
|
: `0 0 40px ${tokens.accentGlow}33, 0 0 80px rgba(0,100,200,0.1), inset 0 0 20px ${tokens.accentBg}`,
|
||||||
|
animation: 'cardSpin 2.5s ease-in-out infinite',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 10, padding: 16,
|
||||||
|
transformStyle: 'preserve-3d',
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* 浅色卡片装饰线条 */}
|
||||||
|
{isLight && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: 3,
|
||||||
|
background: `linear-gradient(to right, #1e40af, #3b82f6, #1e40af)`,
|
||||||
|
borderRadius: '16px 16px 0 0',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
<div style={{ color: costColors[0], fontSize: 13, fontWeight: 700, letterSpacing: '0.04em', fontFamily: 'monospace', marginTop: isLight ? 4 : 0 }}>
|
||||||
|
{order.orderNo}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.text, fontSize: 16, fontWeight: 800 }}>
|
||||||
|
{order.customer}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 12 }}>
|
||||||
|
{order.productCategory}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||||
|
{costColors.map((c, i) => (
|
||||||
|
<span key={i} style={{
|
||||||
|
width: 8, height: 8, borderRadius: '50%',
|
||||||
|
background: c, opacity: isLight ? 0.75 : 0.8,
|
||||||
|
animation: `dotPulse 1.2s ${i * 0.3}s ease-in-out infinite`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 思考文字 */}
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
color: tokens.textSub, fontSize: 16, fontWeight: 700,
|
||||||
|
marginBottom: 10, letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
AI 正在分析订单成本...
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
|
color: tokens.textMuted, fontSize: 13,
|
||||||
|
}}>
|
||||||
|
<span style={{ animation: 'processDot 1.4s infinite' }}>•</span>
|
||||||
|
<span>计算原材料成本、直接人工成本、管理成本偏差</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||||
|
}}>
|
||||||
|
{[0, 1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} style={{
|
||||||
|
width: 4, height: 16, borderRadius: 2,
|
||||||
|
background: costColors[Math.min(i, costColors.length - 1)],
|
||||||
|
opacity: isLight ? 0.5 + i * 0.08 : 1,
|
||||||
|
animation: `barWave 0.8s ${i * 0.12}s ease-in-out infinite alternate`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSS 动画 */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes cardSpin {
|
||||||
|
0% { transform: rotateY(0deg) rotateX(2deg); }
|
||||||
|
25% { transform: rotateY(15deg) rotateX(-2deg); }
|
||||||
|
50% { transform: rotateY(0deg) rotateX(2deg); }
|
||||||
|
75% { transform: rotateY(-15deg) rotateX(-2deg); }
|
||||||
|
100% { transform: rotateY(0deg) rotateX(2deg); }
|
||||||
|
}
|
||||||
|
@keyframes dotPulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.5; }
|
||||||
|
50% { transform: scale(1.6); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes processDot {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes barWave {
|
||||||
|
0% { height: 6px; opacity: 0.3; }
|
||||||
|
100% { height: 22px; opacity: 0.8; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 导出组件 ──────────────────────────────────────────────────────────────────
|
||||||
|
export default function CenterProfitTable({ order, onDrop, isProcessing, processingOrder }: CenterProfitTableProps) {
|
||||||
|
if (isProcessing && processingOrder) return <ProcessingAnimation order={processingOrder} />;
|
||||||
|
if (!order) return <EmptyState onDrop={onDrop} />;
|
||||||
|
return <ProfitTable order={order} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState, useEffect, useRef, type DragEvent } from 'react';
|
||||||
|
import type { Order } from '../data/orders';
|
||||||
|
import CenterProfitTable from './CenterProfitTable';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
order: Order | null;
|
||||||
|
resetKey?: number;
|
||||||
|
onDrop?: (event: DragEvent<HTMLDivElement>) => void;
|
||||||
|
isProcessing?: boolean;
|
||||||
|
processingOrder?: Order | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOADING_STEPS = [
|
||||||
|
'读取订单BOM数据…',
|
||||||
|
'计算直接材料与人工成本…',
|
||||||
|
'分摊制造费用…',
|
||||||
|
'校验成本勾稽关系…',
|
||||||
|
'生成理论成本报告…',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CenterTheoryCost({ order, resetKey, onDrop, isProcessing, processingOrder }: Props) {
|
||||||
|
const { tokens } = useTheme();
|
||||||
|
// loading 初始值跟随 order 是否存在,有订单时立即显示动画
|
||||||
|
const [loading, setLoading] = useState(!!order);
|
||||||
|
const [stepIdx, setStepIdx] = useState(0);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [showResult, setShowResult] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// ── Hook 必须在所有条件返回之前 ──────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!order) {
|
||||||
|
// 无订单 → 重置所有状态
|
||||||
|
setLoading(false);
|
||||||
|
setStepIdx(0);
|
||||||
|
setProgress(0);
|
||||||
|
setShowResult(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 有订单 → 播放 AI 测算动画(约 1.5 秒)
|
||||||
|
setLoading(true);
|
||||||
|
setStepIdx(0);
|
||||||
|
setProgress(0);
|
||||||
|
setShowResult(false);
|
||||||
|
|
||||||
|
let si = 0;
|
||||||
|
const tick = () => {
|
||||||
|
si++;
|
||||||
|
setStepIdx(si);
|
||||||
|
setProgress(Math.min(100, Math.round((si / LOADING_STEPS.length) * 100)));
|
||||||
|
if (si < LOADING_STEPS.length) {
|
||||||
|
timerRef.current = setTimeout(tick, 180 + Math.random() * 100);
|
||||||
|
} else {
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
setShowResult(true);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timerRef.current = setTimeout(tick, 200);
|
||||||
|
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||||
|
}, [order]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!order) return;
|
||||||
|
setShowResult(false);
|
||||||
|
setLoading(true);
|
||||||
|
setStepIdx(0);
|
||||||
|
setProgress(0);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
// ── 条件渲染(hooks 之后) ─────────────────────────────────
|
||||||
|
|
||||||
|
// 无订单 → 空状态提示(立即渲染,绝不闪黑屏)
|
||||||
|
if (!order) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||||
|
onDrop={onDrop}
|
||||||
|
style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 80, height: 80, borderRadius: 20, background: tokens.accentBg, border: `2px dashed ${tokens.borderAccent}44`, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 20, fontSize: 36 }}>📐</div>
|
||||||
|
<h3 style={{ color: tokens.text, fontSize: 18, fontWeight: 700, marginBottom: 8 }}>智能测算理论成本</h3>
|
||||||
|
<p style={{ color: tokens.textMuted, fontSize: 13, textAlign: 'center', maxWidth: 400, lineHeight: 1.8 }}>
|
||||||
|
选择左侧订单后,AI 将自动进行理论成本智能测算,<br/>生成标准BOM用量的三维成本对比分析表。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单处理中(拖拽刚发生) → 处理动画
|
||||||
|
if (isProcessing && !loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{ width: 56, height: 56, borderRadius: '50%', border: `3px solid ${tokens.accent}22`, borderTopColor: tokens.accent, animation: 'space-pulse 1s linear infinite' }} />
|
||||||
|
<p style={{ marginTop: 16, color: tokens.accent, fontSize: 15, fontWeight: 600 }}>正在处理订单…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 测算动画
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||||
|
{/* AI 测算动画 */}
|
||||||
|
<div style={{ width: 64, height: 64, borderRadius: '50%', background: `linear-gradient(135deg, ${tokens.accent}22, ${tokens.accentGlow}22)`, border: `2px solid ${tokens.accent}44`, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 24, position: 'relative' }}>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ animation: 'space-pulse 1.5s ease-in-out infinite' }}>
|
||||||
|
<circle cx="14" cy="14" r="10" stroke={tokens.accent} strokeWidth="1.5" opacity="0.4"/>
|
||||||
|
<path d="M10 14l3 3 5-6" stroke={tokens.accent} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<div style={{ position: 'absolute', inset: -4, borderRadius: '50%', border: `2px solid ${tokens.accent}`, borderTopColor: 'transparent', animation: 'spin 1s linear infinite', opacity: 0.5 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ color: tokens.accent, fontSize: 18, fontWeight: 700, marginBottom: 6, letterSpacing: '0.05em' }}>📐 智能测算中</h3>
|
||||||
|
<p style={{ color: tokens.textMuted, fontSize: 12, marginBottom: 24 }}>
|
||||||
|
{order.customer} · {order.productCategory} · {order.rows[0]?.qty.toLocaleString()} 件
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div style={{ width: 320, height: 4, borderRadius: 2, background: `${tokens.border}44`, marginBottom: 20, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${progress}%`, height: '100%', borderRadius: 2, background: `linear-gradient(90deg, ${tokens.accent}, ${tokens.accentGlow})`, transition: 'width 0.4s ease' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 步骤列表 */}
|
||||||
|
<div style={{ width: 320 }}>
|
||||||
|
{LOADING_STEPS.map((step, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '4px 0', fontSize: 12,
|
||||||
|
color: i < stepIdx ? tokens.success : i === stepIdx ? tokens.accent : `${tokens.textMuted}44`,
|
||||||
|
fontWeight: i === stepIdx ? 600 : 400,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 16, textAlign: 'center', flexShrink: 0 }}>
|
||||||
|
{i < stepIdx ? '✓' : i === stepIdx ? '▸' : '○'}
|
||||||
|
</span>
|
||||||
|
{step}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测算结果 → 成本分析表
|
||||||
|
if (showResult) {
|
||||||
|
return (
|
||||||
|
<CenterProfitTable
|
||||||
|
order={order}
|
||||||
|
resetKey={resetKey}
|
||||||
|
onDrop={onDrop}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
processingOrder={processingOrder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:永远不 return null,防止黑屏
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||||
|
<div style={{ width: 56, height: 56, borderRadius: '50%', border: `3px solid ${tokens.accent}22`, borderTopColor: tokens.accent, animation: 'spin 1s linear infinite' }} />
|
||||||
|
<p style={{ marginTop: 16, color: tokens.accent, fontSize: 15, fontWeight: 600 }}>准备测算中…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { Order } from '../data/orders';
|
||||||
|
import type { Theme } from '../context/ThemeContext';
|
||||||
|
import { darkTokens, lightTokens } from '../context/ThemeContext';
|
||||||
|
import DataSourceToggle from './DataSourceToggle';
|
||||||
|
|
||||||
|
interface CostHeaderProps {
|
||||||
|
theme?: Theme;
|
||||||
|
onToggleTheme?: () => void;
|
||||||
|
orders: Order[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CostHeader({ theme = 'dark', onToggleTheme, orders }: CostHeaderProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const tokens = theme === 'dark' ? darkTokens : lightTokens;
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const [time, setTime] = useState(new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setTime(new Date()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const timeStr = `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`;
|
||||||
|
const dateStr = `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())}`;
|
||||||
|
|
||||||
|
const totalOrders = orders.length;
|
||||||
|
const closedOrders = orders.filter(o => o.status === 'closed').length;
|
||||||
|
const dangerOrders = orders.filter(o => o.riskLevel === 'danger').length;
|
||||||
|
const warningOrders = orders.filter(o => o.riskLevel === 'warning').length;
|
||||||
|
|
||||||
|
// 计算所有已结单订单的平均净利率(管理成本口径)
|
||||||
|
const closedList = orders.filter(o => o.status === 'closed');
|
||||||
|
const avgNetMargin = closedList.length > 0
|
||||||
|
? closedList.reduce((sum, o) => sum + (o.rows.find(r => r.type === '管理成本')?.netMargin ?? 0), 0) / closedList.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: '在管订单', value: String(totalOrders), color: tokens.accent },
|
||||||
|
{ label: '已结单', value: String(closedOrders), color: tokens.success },
|
||||||
|
{ label: '成本预警', value: String(warningOrders), color: tokens.warning },
|
||||||
|
{ label: '亏损风险', value: String(dangerOrders), color: tokens.danger },
|
||||||
|
{ label: '平均净利率', value: `${(avgNetMargin * 100).toFixed(1)}%`, color: avgNetMargin >= 0.05 ? tokens.success : avgNetMargin >= 0 ? tokens.warning : tokens.danger },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header style={{
|
||||||
|
height: 88, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
padding: '0 24px',
|
||||||
|
background: 'transparent',
|
||||||
|
position: 'relative', zIndex: 10, overflow: 'visible',
|
||||||
|
}}>
|
||||||
|
{/* 顶部扫光线 */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: 1,
|
||||||
|
background: tokens.headerScanLine,
|
||||||
|
boxShadow: `0 0 12px ${tokens.accentGlow}`, zIndex: 2,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 底部弧形 */}
|
||||||
|
<svg style={{
|
||||||
|
position: 'absolute', bottom: -26, left: 0,
|
||||||
|
width: '100%', height: 28,
|
||||||
|
pointerEvents: 'none', zIndex: 0, overflow: 'visible',
|
||||||
|
}} viewBox="0 0 1000 28" preserveAspectRatio="none">
|
||||||
|
<path d="M 0,0 C 250,28 750,28 1000,0" fill="none" stroke={tokens.svgStroke} strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<path d="M 0,0 C 250,28 750,28 1000,0" fill="none" stroke={tokens.svgDot.replace('0.9', '0.1')} strokeWidth="6" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="0" cy="0" r="2.5" fill={tokens.svgDot.replace('0.9', '0.6')} />
|
||||||
|
<circle cx="1000" cy="0" r="2.5" fill={tokens.svgDot.replace('0.9', '0.6')} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Logo + 标题 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flex: 1, position: 'relative', zIndex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
height: 50, borderRadius: 8,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: isLight ? '#ffffff' : 'rgba(235,242,255,0.95)',
|
||||||
|
padding: '0 10px',
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src="/regal-rexnord-logo.png"
|
||||||
|
alt="Regal Rexnord"
|
||||||
|
style={{ height: '92%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<h1 style={{
|
||||||
|
color: tokens.text, fontSize: 19, fontWeight: 700,
|
||||||
|
letterSpacing: '0.05em', margin: 0,
|
||||||
|
textShadow: tokens.titleGlow,
|
||||||
|
}}>
|
||||||
|
订单成本枢纽平台
|
||||||
|
</h1>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '2px 8px', borderRadius: 10,
|
||||||
|
background: tokens.accentBg, border: `1px solid ${tokens.accent}55`,
|
||||||
|
fontSize: 12, color: tokens.accent, fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 5, height: 5, borderRadius: '50%',
|
||||||
|
backgroundColor: tokens.success,
|
||||||
|
boxShadow: `0 0 8px ${tokens.success}`,
|
||||||
|
animation: 'space-pulse 2s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔线 */}
|
||||||
|
<div style={{ width: 1, height: 44, background: `linear-gradient(to bottom, transparent, ${tokens.accent}33, transparent)`, flexShrink: 0, marginRight: 24, position: 'relative', zIndex: 1 }} />
|
||||||
|
|
||||||
|
{/* 统计指标 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'center', position: 'relative', zIndex: 1, marginTop: 24 }}>
|
||||||
|
{stats.map((item, idx) => (
|
||||||
|
<div key={item.label} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center', padding: '6px 16px', borderRadius: 10,
|
||||||
|
background: tokens.kpiCardBg, border: `1px solid ${tokens.kpiCardBorder}`,
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: item.color, fontSize: 24, fontWeight: 800, lineHeight: 1, textShadow: isLight ? 'none' : `0 0 12px ${item.color}80` }}>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: isLight ? tokens.textSub : tokens.textMuted, fontSize: 13, marginTop: 4, letterSpacing: '0.04em', fontWeight: 600 }}>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{idx < stats.length - 1 && <div style={{ width: 1, height: 36, background: `${tokens.accent}18`, margin: '0 3px' }} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右分隔线 */}
|
||||||
|
<div style={{ width: 1, height: 44, background: `linear-gradient(to bottom, transparent, ${tokens.accent}33, transparent)`, flexShrink: 0, marginLeft: 24, position: 'relative', zIndex: 1 }} />
|
||||||
|
|
||||||
|
{/* 时间 + 切换按钮 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flex: 1, justifyContent: 'flex-end', position: 'relative', zIndex: 1 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px', borderRadius: 8, cursor: 'pointer',
|
||||||
|
border: `1px solid ${tokens.accent}55`,
|
||||||
|
background: tokens.accentBg,
|
||||||
|
color: tokens.accent, fontSize: 12, fontWeight: 600,
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = `${tokens.accent}18`; e.currentTarget.style.borderColor = tokens.accent; }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = tokens.accentBg; e.currentTarget.style.borderColor = `${tokens.accent}55`; }}
|
||||||
|
>
|
||||||
|
← 返回平台
|
||||||
|
</button>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ color: tokens.accent, fontSize: 20, fontWeight: 700, fontVariantNumeric: 'tabular-nums', lineHeight: 1, textShadow: isLight ? 'none' : `0 0 16px ${tokens.accentGlow}`, letterSpacing: '0.05em' }}>
|
||||||
|
{timeStr}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: tokens.textMuted, fontSize: 11, marginTop: 2, letterSpacing: '0.04em' }}>
|
||||||
|
{dateStr}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: `${tokens.accent}88`, fontSize: 11, marginTop: 4, letterSpacing: '0.06em' }}>
|
||||||
|
Powered by EIOS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 切换风格按钮 */}
|
||||||
|
{onToggleTheme && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
title={theme === 'dark' ? '切换浅色风格' : '切换深色风格'}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '5px 12px', borderRadius: 20,
|
||||||
|
border: `1px solid ${tokens.accent}55`,
|
||||||
|
background: tokens.accentBg,
|
||||||
|
color: tokens.accent,
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.background = `${tokens.accent}18`;
|
||||||
|
e.currentTarget.style.borderColor = tokens.accent;
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.background = tokens.accentBg;
|
||||||
|
e.currentTarget.style.borderColor = `${tokens.accent}55`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
||||||
|
<circle cx="6.5" cy="6.5" r="3" stroke="currentColor" strokeWidth="1.3"/>
|
||||||
|
<path d="M6.5 1 L6.5 2.2 M6.5 10.8 L6.5 12 M1 6.5 L2.2 6.5 M10.8 6.5 L12 6.5 M2.7 2.7 L3.6 3.6 M9.4 9.4 L10.3 10.3 M10.3 2.7 L9.4 3.6 M3.6 9.4 L2.7 10.3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
||||||
|
<path d="M10.5 7.5 C9.8 9.8 7.6 11.5 5 11.5 C2.2 11.5 0 9.3 0 6.5 C0 3.9 1.7 1.7 4 1 C3.2 2 2.8 3.2 2.8 4.5 C2.8 7.5 5.3 10 8.3 10 C9.1 10 9.9 9.8 10.5 9.4 Z" fill="currentColor" opacity="0.8"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{theme === 'dark' ? '浅色' : '深色'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 数据源切换 */}
|
||||||
|
<DataSourceToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* DataSourceToggle — 数据源切换按钮 (order-cost-analysis)
|
||||||
|
* 适配深色/浅色双主题,从 useTheme() 获取 tokens 配色
|
||||||
|
*/
|
||||||
|
import { useDataSource } from '../context/DataSourceContext';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
export default function DataSourceToggle() {
|
||||||
|
const { mode, toggle, apiStatus, isFallback } = useDataSource();
|
||||||
|
const { tokens } = useTheme();
|
||||||
|
const isApi = mode === 'api';
|
||||||
|
|
||||||
|
const statusDot = isApi
|
||||||
|
? apiStatus === 'online' ? tokens.success : apiStatus === 'offline' ? tokens.danger : tokens.textMuted
|
||||||
|
: 'transparent';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: 3, borderRadius: 10,
|
||||||
|
backgroundColor: tokens.panelBg,
|
||||||
|
border: `1px solid ${tokens.border}`,
|
||||||
|
}}>
|
||||||
|
{/* Static */}
|
||||||
|
<button onClick={toggle} style={{
|
||||||
|
padding: '5px 12px', borderRadius: 8,
|
||||||
|
fontSize: 11, fontWeight: 500, letterSpacing: '-0.01em',
|
||||||
|
cursor: 'pointer', border: 'none',
|
||||||
|
backgroundColor: !isApi ? tokens.accentBg : 'transparent',
|
||||||
|
color: !isApi ? tokens.accent : tokens.textMuted,
|
||||||
|
boxShadow: !isApi ? `0 0 8px ${tokens.accentGlow}` : 'none',
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}>
|
||||||
|
静态
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* API */}
|
||||||
|
<button onClick={toggle} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '5px 12px', borderRadius: 8,
|
||||||
|
fontSize: 11, fontWeight: 500, letterSpacing: '-0.01em',
|
||||||
|
cursor: 'pointer', border: 'none',
|
||||||
|
backgroundColor: isApi ? tokens.accentBg : 'transparent',
|
||||||
|
color: isApi ? tokens.accent : tokens.textMuted,
|
||||||
|
boxShadow: isApi ? `0 0 8px ${tokens.accentGlow}` : 'none',
|
||||||
|
transition: 'all 0.18s ease',
|
||||||
|
}}>
|
||||||
|
API
|
||||||
|
{isApi && (
|
||||||
|
<span style={{
|
||||||
|
width: 6, height: 6, borderRadius: '50%',
|
||||||
|
backgroundColor: statusDot,
|
||||||
|
boxShadow: apiStatus === 'online' ? `0 0 6px ${tokens.success}` : apiStatus === 'offline' ? `0 0 6px ${tokens.danger}` : 'none',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isFallback && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, fontWeight: 500,
|
||||||
|
color: tokens.warning,
|
||||||
|
backgroundColor: tokens.warningBg,
|
||||||
|
padding: '2px 6px', borderRadius: 8,
|
||||||
|
border: `1px solid ${tokens.warning}40`,
|
||||||
|
marginLeft: 2,
|
||||||
|
}}>
|
||||||
|
回退
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { DEMANDS, maturityConfig, statusConfig, type AIDemand } from '../data/demands';
|
||||||
|
import TechIcon from './TechIcons';
|
||||||
|
|
||||||
|
interface LeftDemandPanelProps {
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (demand: AIDemand) => void;
|
||||||
|
onExpandChange?: (expanded: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const industryColors: Record<string, string> = {
|
||||||
|
'智能电气': '#00d4ff',
|
||||||
|
'鞋业服装': '#ff6b9d',
|
||||||
|
'汽车零部件': '#ff8c42',
|
||||||
|
'工业泵阀': '#7b61ff',
|
||||||
|
'智能装备': '#4fc3f7',
|
||||||
|
'新能源': '#00e599',
|
||||||
|
'新材料': '#ffb340',
|
||||||
|
'智能物联': '#35d0ba',
|
||||||
|
'眼镜': '#9bdcff',
|
||||||
|
'食品农产品': '#8bd450',
|
||||||
|
'低空经济': '#b58cff',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeftDemandPanel({ selectedId, onSelect, onExpandChange }: LeftDemandPanelProps) {
|
||||||
|
const [filter, setFilter] = useState<'all' | 'new' | 'matching' | 'matched' | 'pilot'>('all');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const filtered = filter === 'all' ? DEMANDS : DEMANDS.filter(d => d.status === filter);
|
||||||
|
const urgentCount = DEMANDS.filter(d => d.urgency === 'high').length;
|
||||||
|
|
||||||
|
const handleExpand = () => {
|
||||||
|
const next = !expanded;
|
||||||
|
setExpanded(next);
|
||||||
|
onExpandChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const W = expanded ? 460 : 260;
|
||||||
|
const BULGE = 28;
|
||||||
|
const totalW = W + BULGE;
|
||||||
|
|
||||||
|
const outlinePath = `M 0,0 L ${W - 70},0 Q ${W},0 ${W},80 C ${W + BULGE},2000 ${W + BULGE},8000 ${W},9920 L 0,10000 Z`;
|
||||||
|
const strokePath = `M 0,0.5 L ${W - 70},0.5 Q ${W - 0.5},0.5 ${W - 0.5},80 C ${W - 0.5 + BULGE},2000 ${W - 0.5 + BULGE},8000 ${W - 0.5},9920`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-left" style={{
|
||||||
|
width: totalW, height: '100%', flexShrink: 0,
|
||||||
|
position: 'relative', overflow: 'visible',
|
||||||
|
background: 'transparent',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
<svg style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, overflow: 'visible' }}
|
||||||
|
viewBox={`0 0 ${totalW} 10000`} preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="leftDemandBg" x1="1" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(4,28,75,0.88)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(2,14,48,0.78)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={outlinePath} fill="url(#leftDemandBg)" />
|
||||||
|
<path d={strokePath} fill="none" stroke="rgba(0,212,255,0.45)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<line x1="0.5" y1="0" x2="0.5" y2="10000" stroke="rgba(0,212,255,0.15)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W - 70} cy="1" r="3" fill="rgba(0,212,255,0.9)" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W} cy="80" r="2.5" fill="rgba(0,212,255,0.6)" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W + BULGE} cy="5000" r="2" fill="rgba(0,212,255,0.25)" vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1, width: W,
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
{/* 标题区 */}
|
||||||
|
<div style={{ padding: '14px 12px 10px 14px', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<span style={{ width: 3, height: 16, borderRadius: 2, background: 'linear-gradient(to bottom,#00d4ff,#0066aa)', display: 'inline-block' }} />
|
||||||
|
<span style={{ color: '#a8d8ff', fontSize: 16, fontWeight: 600, letterSpacing: '0.04em' }}>企业AI需求</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{urgentCount > 0 && (
|
||||||
|
<span style={{ padding: '1px 7px', borderRadius: 8, background: 'rgba(255,140,66,0.2)', color: '#ff8c42', fontSize: 12, fontWeight: 700 }}>
|
||||||
|
{urgentCount}紧急
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={handleExpand} style={{
|
||||||
|
padding: '3px 10px', borderRadius: 7,
|
||||||
|
border: `1px solid ${expanded ? 'rgba(0,212,255,0.4)' : 'rgba(0,212,255,0.2)'}`,
|
||||||
|
background: expanded ? 'rgba(0,212,255,0.1)' : 'transparent',
|
||||||
|
color: expanded ? '#00d4ff' : 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{expanded ? <><TechIcon name="collapse" color="currentColor" size={13} /> 收起</> : <><TechIcon name="expand" color="currentColor" size={13} /> 展开</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选标签 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 10, flexWrap: 'wrap' }}>
|
||||||
|
{(['all', 'new', 'matching', 'pilot'] as const).map(s => {
|
||||||
|
const isActive = filter === s;
|
||||||
|
const cfg = s !== 'all' ? statusConfig[s] : null;
|
||||||
|
return (
|
||||||
|
<button key={s} onClick={() => setFilter(s)} style={{
|
||||||
|
flex: '1 1 0', minWidth: 0, padding: '4px 0', borderRadius: 5,
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1px solid ${isActive ? (cfg?.color + '60' || 'rgba(0,212,255,0.4)') : 'rgba(255,255,255,0.12)'}`,
|
||||||
|
background: isActive ? (cfg?.color + '18' || 'rgba(0,212,255,0.12)') : 'transparent',
|
||||||
|
color: isActive ? (cfg?.color || '#00d4ff') : 'rgba(255,255,255,0.55)',
|
||||||
|
}}>
|
||||||
|
{s === 'all' ? '全部' : cfg!.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'rgba(0,212,255,0.1)', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 需求列表 */}
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 8px 14px' }}>
|
||||||
|
{filtered.map((demand, idx) => {
|
||||||
|
const isSelected = selectedId === demand.id;
|
||||||
|
const mCfg = maturityConfig[demand.maturityLevel];
|
||||||
|
const sCfg = statusConfig[demand.status];
|
||||||
|
const indColor = industryColors[demand.industry] || '#00d4ff';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={demand.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={e => {
|
||||||
|
e.dataTransfer.setData('application/x-ai-demand-id', demand.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
}}
|
||||||
|
onClick={() => onSelect(demand)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 11px', borderRadius: 10, marginBottom: 7,
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff55' : 'rgba(0,212,255,0.12)'}`,
|
||||||
|
background: isSelected
|
||||||
|
? 'linear-gradient(135deg, rgba(0,212,255,0.12) 0%, rgba(0,100,200,0.06) 100%)'
|
||||||
|
: 'rgba(0,20,50,0.35)',
|
||||||
|
cursor: 'pointer', transition: 'all 0.18s ease',
|
||||||
|
animation: `notice-in 0.3s ${idx * 0.05}s both`,
|
||||||
|
boxShadow: isSelected ? '0 0 14px rgba(0,212,255,0.15)' : 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,30,70,0.6)'; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,20,50,0.35)'; }}
|
||||||
|
>
|
||||||
|
{/* 顶行:公司名 + 状态 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: isSelected ? '#00d4ff' : '#c8e4ff', fontSize: 13, fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{demand.company}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(0,212,255,0.62)', fontSize: 10, marginTop: 2, letterSpacing: '0.04em' }}>
|
||||||
|
拖拽至中间模块生成Agentic内容
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11, marginTop: 1 }}>
|
||||||
|
{demand.region}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 6px', borderRadius: 5, flexShrink: 0,
|
||||||
|
background: `${sCfg.color}18`, border: `1px solid ${sCfg.color}40`,
|
||||||
|
color: sCfg.color, fontSize: 11, fontWeight: 600,
|
||||||
|
}}>{sCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 痛点描述 */}
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.78)', fontSize: 12, margin: '6px 0 5px', lineHeight: 1.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{demand.painPoint}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 底行:行业标签 + 成熟度 + 场景 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 4, background: `${indColor}18`, border: `1px solid ${indColor}35`, color: indColor, fontSize: 11, fontWeight: 600 }}>
|
||||||
|
{demand.industry}
|
||||||
|
</span>
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 4, background: mCfg.bg, border: `1px solid ${mCfg.color}40`, color: mCfg.color, fontSize: 11, fontWeight: 600 }}>
|
||||||
|
{mCfg.label}
|
||||||
|
</span>
|
||||||
|
{demand.urgency === 'high' && (
|
||||||
|
<span style={{ padding: '1px 5px', borderRadius: 4, background: 'rgba(255,140,66,0.12)', color: '#ff8c42', fontSize: 10, fontWeight: 700 }}>
|
||||||
|
!紧急
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 成熟度进度条 */}
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<div style={{ flex: 1, height: 3, borderRadius: 2, background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${demand.maturityScore}%`, height: '100%', background: `linear-gradient(to right, ${mCfg.color}88, ${mCfg.color})`, borderRadius: 2, transition: 'width 0.5s ease' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: mCfg.color, fontSize: 11, fontWeight: 700, flexShrink: 0 }}>{demand.maturityScore}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { statusConfig, riskConfig, deliveryPriorityConfig, type Order } from '../data/orders';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface LeftOrderPanelProps {
|
||||||
|
selectedId: string | null;
|
||||||
|
onExpandChange?: (expanded: boolean) => void;
|
||||||
|
orders: Order[];
|
||||||
|
onSelect?: (order: Order) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterStatus = 'all' | 'closed' | 'production' | 'shipped';
|
||||||
|
type FilterRisk = 'all' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
export default function LeftOrderPanel({ selectedId, onExpandChange, orders, onSelect }: LeftOrderPanelProps) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
|
||||||
|
// 浅色模式下标签颜色统一收敛:状态用accent蓝色,风险保留功能色,渠道用灰色
|
||||||
|
const getLabelColor = (rawColor: string, type: 'status' | 'risk' | 'channel') => {
|
||||||
|
if (!isLight) return rawColor;
|
||||||
|
if (type === 'risk') return rawColor; // 风险保留原色(success/warning/danger)
|
||||||
|
return tokens.accent; // status和channel统一用蓝色
|
||||||
|
};
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
|
const wasDragged = useRef(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<FilterStatus>('all');
|
||||||
|
const [riskFilter, setRiskFilter] = useState<FilterRisk>('all');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const handleExpand = () => {
|
||||||
|
const next = !expanded;
|
||||||
|
setExpanded(next);
|
||||||
|
onExpandChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
let filtered = orders;
|
||||||
|
if (statusFilter !== 'all') filtered = filtered.filter(o => o.status === statusFilter);
|
||||||
|
if (riskFilter !== 'all') filtered = filtered.filter(o => o.riskLevel === riskFilter);
|
||||||
|
|
||||||
|
const W = expanded ? 460 : 268;
|
||||||
|
const BULGE = 14;
|
||||||
|
const totalW = W + BULGE;
|
||||||
|
const outlinePath = `M 0,0 L ${W - 70},0 Q ${W},0 ${W},80 C ${W + BULGE},2000 ${W + BULGE},8000 ${W},9920 L 0,10000 Z`;
|
||||||
|
const strokePath = `M 0,0.5 L ${W - 70},0.5 Q ${W - 0.5},0.5 ${W - 0.5},80 C ${W - 0.5 + BULGE},2000 ${W - 0.5 + BULGE},8000 ${W - 0.5},9920`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-left" style={{
|
||||||
|
width: totalW, height: '100%', flexShrink: 0,
|
||||||
|
position: 'relative', overflow: 'visible',
|
||||||
|
background: 'transparent',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
<svg style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, overflow: 'visible' }}
|
||||||
|
viewBox={`0 0 ${totalW} 10000`} preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="leftOrderBg" x1="1" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={tokens.svgPanelFrom} />
|
||||||
|
<stop offset="100%" stopColor={tokens.svgPanelTo} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={outlinePath} fill="url(#leftOrderBg)" />
|
||||||
|
<path d={strokePath} fill="none" stroke={tokens.svgStroke} strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<line x1="0.5" y1="0" x2="0.5" y2="10000" stroke={tokens.svgStroke.replace('0.45', '0.15')} strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W - 70} cy="1" r="3" fill={tokens.svgDot} vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W} cy="80" r="2.5" fill={tokens.svgStroke} vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={W + BULGE} cy="5000" r="2" fill={tokens.svgStroke.replace('0.45', '0.25')} vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1, width: W,
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
background: 'transparent',
|
||||||
|
}}>
|
||||||
|
{/* 标题区 */}
|
||||||
|
<div style={{ padding: '14px 12px 10px 14px', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<span style={{ width: 3, height: 16, borderRadius: 2, background: `linear-gradient(to bottom,${tokens.accent},${tokens.accent}88)`, display: 'inline-block' }} />
|
||||||
|
<span style={{ color: tokens.textSub, fontSize: 19, fontWeight: 700, letterSpacing: '0.04em' }}>客户订单</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<button onClick={handleExpand} style={{
|
||||||
|
padding: '3px 8px', borderRadius: 7,
|
||||||
|
border: `1px solid ${expanded ? tokens.accent + '60' : tokens.border}`,
|
||||||
|
background: expanded ? tokens.accentBg : 'transparent',
|
||||||
|
color: expanded ? tokens.accent : tokens.textMuted,
|
||||||
|
fontSize: 12, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{expanded ? '收起' : '展开'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态筛选 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 10 }}>
|
||||||
|
{(['all', 'closed', 'production', 'shipped'] as FilterStatus[]).map(s => {
|
||||||
|
const isActive = statusFilter === s;
|
||||||
|
const cfg = s !== 'all' ? statusConfig[s] : null;
|
||||||
|
return (
|
||||||
|
<button key={s} onClick={() => setStatusFilter(s)} style={{
|
||||||
|
flex: 1, padding: '5px 0', borderRadius: 5, fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1px solid ${isActive ? tokens.accent + '60' : tokens.borderSub}`,
|
||||||
|
background: isActive ? tokens.accentBg : 'transparent',
|
||||||
|
color: isActive ? tokens.accent : tokens.textMuted,
|
||||||
|
}}>
|
||||||
|
{s === 'all' ? '全部' : cfg!.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 风险筛选 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 5 }}>
|
||||||
|
{(['all', 'warning', 'danger'] as FilterRisk[]).map(r => {
|
||||||
|
const isActive = riskFilter === r;
|
||||||
|
const cfg = r !== 'all' ? riskConfig[r] : null;
|
||||||
|
return (
|
||||||
|
<button key={r} onClick={() => setRiskFilter(r)} style={{
|
||||||
|
flex: 1, padding: '4px 0', borderRadius: 5, fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1px solid ${isActive ? (cfg?.color + '60' || tokens.accent + '60') : tokens.borderSub}`,
|
||||||
|
background: isActive ? (cfg?.color ? cfg.color + '10' : tokens.accentBg) : 'transparent',
|
||||||
|
color: isActive ? (cfg ? getLabelColor(cfg.color, 'risk') : tokens.accent) : tokens.textMuted,
|
||||||
|
}}>
|
||||||
|
{r === 'all' ? '风险:全部' : cfg!.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: tokens.border, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 订单列表 */}
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 8px 14px' }}>
|
||||||
|
{filtered.map((order, idx) => {
|
||||||
|
const isSelected = selectedId === order.id;
|
||||||
|
const sCfg = statusConfig[order.status];
|
||||||
|
const rCfg = riskConfig[order.riskLevel];
|
||||||
|
const dpCfg = deliveryPriorityConfig[order.deliveryPriority];
|
||||||
|
|
||||||
|
const isDragging = draggingId === order.id;
|
||||||
|
return (
|
||||||
|
<div key={order.id}
|
||||||
|
draggable
|
||||||
|
onClick={() => { if (!wasDragged.current) onSelect?.(order); wasDragged.current = false; }}
|
||||||
|
onDragStart={e => {
|
||||||
|
e.dataTransfer.setData('application/x-order-json', JSON.stringify(order));
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
setDraggingId(order.id);
|
||||||
|
wasDragged.current = true;
|
||||||
|
}}
|
||||||
|
onDragEnd={() => { setDraggingId(null); setTimeout(() => { wasDragged.current = false; }, 50); }}
|
||||||
|
style={{
|
||||||
|
padding: '10px 11px', borderRadius: 10, marginBottom: 7,
|
||||||
|
border: `1px solid ${isSelected ? tokens.accent + '55' : isDragging ? tokens.accent + '99' : (order.riskLevel === 'danger' ? tokens.danger + '40' : tokens.border)}`,
|
||||||
|
borderLeft: isLight ? `3px solid ${isSelected ? tokens.accent : (order.riskLevel === 'danger' ? tokens.danger : order.riskLevel === 'warning' ? tokens.warning : 'rgba(30,60,130,0.25)')}` : `1px solid ${isSelected ? tokens.accent + '55' : isDragging ? tokens.accent + '99' : (order.riskLevel === 'danger' ? tokens.danger + '40' : tokens.border)}`,
|
||||||
|
background: isSelected
|
||||||
|
? `linear-gradient(135deg, ${tokens.accent}18 0%, ${tokens.accent}08 100%)`
|
||||||
|
: isDragging ? `${tokens.accent}18`
|
||||||
|
: order.riskLevel === 'danger' ? tokens.dangerBg : (isLight ? '#ffffff' : tokens.panelBg),
|
||||||
|
cursor: 'pointer', transition: 'all 0.18s ease',
|
||||||
|
animation: `notice-in 0.3s ${idx * 0.05}s both`,
|
||||||
|
boxShadow: isSelected ? `0 0 14px ${tokens.accent}25` : isDragging ? `0 0 18px ${tokens.accent}45` : (isLight ? '0 1px 4px rgba(30,60,130,0.06)' : 'none'),
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
transform: isDragging ? 'scale(0.97)' : 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isSelected && !isDragging) e.currentTarget.style.background = isLight ? '#f0f4ff' : tokens.panelBgAlt; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected && !isDragging) e.currentTarget.style.background = order.riskLevel === 'danger' ? tokens.dangerBg : (isLight ? '#ffffff' : tokens.panelBg); }}
|
||||||
|
>
|
||||||
|
{/* 第一行:订单号 + 状态 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: isSelected ? tokens.accent : tokens.textSub, fontSize: 13, fontFamily: 'monospace', letterSpacing: '0.02em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||||
|
{order.orderNo}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 6px', borderRadius: 4, flexShrink: 0,
|
||||||
|
background: isLight ? tokens.accentBg : `${sCfg.color}18`,
|
||||||
|
border: `1px solid ${isLight ? tokens.borderAccent : sCfg.color + '40'}`,
|
||||||
|
color: isLight ? tokens.accent : sCfg.color, fontSize: 12, fontWeight: 600,
|
||||||
|
}}>{sCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第二行:客户 + 风险 */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 4 }}>
|
||||||
|
<span style={{ color: tokens.text, fontSize: 15, fontWeight: 700 }}>{order.customer}</span>
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 6px', borderRadius: 4,
|
||||||
|
background: isLight ? (order.riskLevel === 'danger' ? tokens.dangerBg : order.riskLevel === 'warning' ? tokens.warningBg : tokens.successBg) : rCfg.bg,
|
||||||
|
border: `1px solid ${isLight ? (order.riskLevel === 'danger' ? tokens.danger + '40' : order.riskLevel === 'warning' ? tokens.warning + '40' : tokens.success + '40') : rCfg.color + '40'}`,
|
||||||
|
color: isLight ? (order.riskLevel === 'danger' ? tokens.danger : order.riskLevel === 'warning' ? tokens.warning : tokens.success) : rCfg.color,
|
||||||
|
fontSize: 12, fontWeight: 700,
|
||||||
|
}}>{rCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第三行:品类 + 渠道 */}
|
||||||
|
<div style={{ display: 'flex', gap: 5, alignItems: 'center', marginTop: 4, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ color: isLight ? tokens.textSub : tokens.textMuted, fontSize: 13 }}>{order.productCategory}</span>
|
||||||
|
<span style={{ padding: '2px 6px', borderRadius: 4, background: tokens.accentBg, border: `1px solid ${tokens.borderSub}`, color: isLight ? tokens.textSub : tokens.textMuted, fontSize: 12, fontWeight: 600 }}>
|
||||||
|
{order.channel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 交期优先级 */}
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: isLight ? tokens.textSub : tokens.textMuted, fontSize: 12, flexShrink: 0 }}>交期优先级</span>
|
||||||
|
<div style={{ flex: 1, height: 3, borderRadius: 2, background: tokens.border, overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
width: order.deliveryPriority === 'urgent' ? '100%' : order.deliveryPriority === 'high' ? '75%' : order.deliveryPriority === 'normal' ? '50%' : '25%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 2,
|
||||||
|
background: `linear-gradient(to right,${dpCfg.color}66,${dpCfg.color})`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
padding: '1px 6px', borderRadius: 4, fontSize: 12, fontWeight: 700, flexShrink: 0,
|
||||||
|
background: dpCfg.bg, color: dpCfg.color, border: `1px solid ${dpCfg.color}40`,
|
||||||
|
}}>
|
||||||
|
{dpCfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', gap: 4, flexWrap: 'wrap', animation: 'notice-in 0.2s both' }}>
|
||||||
|
{order.tags.map(tag => (
|
||||||
|
<span key={tag} style={{ padding: '1px 6px', borderRadius: 4, background: tokens.accentBg, border: `1px solid ${tokens.border}`, color: tokens.textSub, fontSize: 10 }}>#{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { type Order, type ProductionLine, type ProductionStep } from '../data/orders';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
lines: ProductionLine[];
|
||||||
|
order: Order;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number, decimals = 0) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProcessStep({ step, isLight, tokens }: {
|
||||||
|
step: ProductionStep;
|
||||||
|
isLight: boolean;
|
||||||
|
tokens: ReturnType<typeof useTheme>['tokens'];
|
||||||
|
}) {
|
||||||
|
const danger = tokens.danger;
|
||||||
|
const warning = tokens.warning;
|
||||||
|
const issueColor = step.defectRate > 0.03 ? danger : warning;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: step.hasIssue
|
||||||
|
? `1.5px solid ${issueColor}`
|
||||||
|
: `1px solid ${isLight ? 'rgba(30,60,130,0.1)' : tokens.borderSub}`,
|
||||||
|
background: step.hasIssue
|
||||||
|
? (isLight ? `${issueColor}0a` : `${issueColor}10`)
|
||||||
|
: (isLight ? 'rgba(241,245,255,0.5)' : 'rgba(0,30,80,0.15)'),
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}>
|
||||||
|
{step.hasIssue && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: -1, right: -1,
|
||||||
|
width: 18, height: 18, borderRadius: '0 8px 0 8px',
|
||||||
|
background: issueColor,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: `0 0 6px ${issueColor}60`,
|
||||||
|
}}>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||||
|
<path d="M5 2.5 L5 5.5" stroke="#fff" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
<circle cx="5" cy="7.5" r="0.7" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 700,
|
||||||
|
color: step.hasIssue ? issueColor : (isLight ? '#1e3a6e' : tokens.text),
|
||||||
|
}}>{step.name}</span>
|
||||||
|
{step.hasIssue && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, fontWeight: 600, color: '#fff',
|
||||||
|
padding: '1px 5px', borderRadius: 4,
|
||||||
|
background: issueColor, letterSpacing: '0.02em',
|
||||||
|
}}>工艺异常</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 10, color: isLight ? tokens.textSub : tokens.textMuted }}>
|
||||||
|
成本 <span style={{ color: isLight ? '#1e3a6e' : tokens.accent, fontWeight: 600 }}>{fmt(step.cost)}</span>
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 10, color: isLight ? tokens.textSub : tokens.textMuted }}>{step.duration}h</span>
|
||||||
|
{step.defectRate > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600,
|
||||||
|
color: step.defectRate > 0.03 ? danger : step.defectRate > 0.015 ? warning : (isLight ? tokens.textSub : tokens.textMuted),
|
||||||
|
}}>
|
||||||
|
不良 {(step.defectRate * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.hasIssue && step.issueDesc && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 4, fontSize: 10, lineHeight: 1.5, color: issueColor, fontStyle: 'italic',
|
||||||
|
borderTop: `1px dashed ${issueColor}30`, paddingTop: 3,
|
||||||
|
}}>{step.issueDesc}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrowDown({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '2px 0' }}>
|
||||||
|
<svg width="12" height="16" viewBox="0 0 12 16" fill="none">
|
||||||
|
<path d="M6 0 L6 12 M2 8 L6 12 L10 8" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineCard({ line, isLight, tokens, isLowest, isSelected, onClick }: {
|
||||||
|
line: ProductionLine;
|
||||||
|
isLight: boolean;
|
||||||
|
tokens: ReturnType<typeof useTheme>['tokens'];
|
||||||
|
isLowest: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const issueCount = line.steps.filter(s => s.hasIssue).length;
|
||||||
|
const eqColor = line.equipment === '自动线'
|
||||||
|
? (isLight ? '#059669' : '#00e599')
|
||||||
|
: line.equipment === '半自动线'
|
||||||
|
? (isLight ? '#dc2626' : '#ff6b6b')
|
||||||
|
: (isLight ? '#d97706' : '#ffb340');
|
||||||
|
const selectColor = isLight ? '#2563eb' : '#00d4ff';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
flex: 1, minWidth: 0,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: isSelected
|
||||||
|
? `2px solid ${selectColor}`
|
||||||
|
: `1px solid ${isLowest ? (isLight ? 'rgba(5,150,105,0.3)' : 'rgba(0,229,153,0.25)') : (isLight ? 'rgba(30,60,130,0.12)' : tokens.border)}`,
|
||||||
|
background: isLight ? '#fff' : 'rgba(0,20,60,0.2)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxShadow: isSelected ? `0 0 12px ${selectColor}30, 0 0 4px ${selectColor}15` : 'none',
|
||||||
|
transform: isSelected ? 'translateY(-2px)' : 'none',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 选中指示器 */}
|
||||||
|
{isSelected && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 6, right: 6, zIndex: 3,
|
||||||
|
width: 20, height: 20, borderRadius: '50%',
|
||||||
|
background: selectColor,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: `0 0 8px ${selectColor}60`,
|
||||||
|
}}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M2.5 6 L5 8.5 L9.5 3.5" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 卡片头部 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.08)' : tokens.borderSub}`,
|
||||||
|
background: isSelected
|
||||||
|
? (isLight ? 'rgba(37,99,235,0.08)' : 'rgba(0,212,255,0.1)')
|
||||||
|
: (isLight ? 'rgba(30,60,130,0.04)' : 'rgba(0,30,80,0.25)'),
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 800, color: isLight ? '#1e3a6e' : tokens.text }}>{line.name}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600, color: eqColor,
|
||||||
|
padding: '1px 6px', borderRadius: 4,
|
||||||
|
background: `${eqColor}18`, border: `1px solid ${eqColor}30`,
|
||||||
|
}}>{line.equipment}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
{isLowest && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, fontWeight: 700, color: '#fff',
|
||||||
|
padding: '2px 6px', borderRadius: 4,
|
||||||
|
background: isLight ? '#059669' : '#00e599',
|
||||||
|
}}>最优</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, fontSize: 10, color: isLight ? tokens.textSub : tokens.textMuted }}>
|
||||||
|
<span>总成本 <span style={{ color: isLight ? '#1e3a6e' : tokens.accent, fontWeight: 700, fontSize: 12 }}>¥{fmt(line.totalCost)}</span></span>
|
||||||
|
<span>工时 {line.totalHours}h</span>
|
||||||
|
<span>良率 <span style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: line.yieldRate >= 0.97 ? (isLight ? '#059669' : '#00e599') : line.yieldRate >= 0.95 ? (isLight ? '#d97706' : '#ffb340') : (isLight ? '#dc2626' : '#ff6b6b'),
|
||||||
|
}}>{(line.yieldRate * 100).toFixed(1)}%</span></span>
|
||||||
|
{issueCount > 0 && (
|
||||||
|
<span style={{ color: isLight ? '#dc2626' : '#ff6b6b', fontWeight: 600 }}>{issueCount}个问题</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 工序流程 */}
|
||||||
|
<div style={{ padding: '8px 10px', flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
|
{line.steps.map((step, i) => (
|
||||||
|
<div key={step.name}>
|
||||||
|
<ProcessStep step={step} isLight={isLight} tokens={tokens} />
|
||||||
|
{i < line.steps.length - 1 && (
|
||||||
|
<ArrowDown color={isLight ? 'rgba(30,60,130,0.2)' : 'rgba(0,212,255,0.2)'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CostBar({ line, maxCost, isLight, tokens }: {
|
||||||
|
line: ProductionLine; maxCost: number;
|
||||||
|
isLight: boolean; tokens: ReturnType<typeof useTheme>['tokens'];
|
||||||
|
}) {
|
||||||
|
const pct = maxCost > 0 ? (line.totalCost / maxCost) * 100 : 0;
|
||||||
|
const barColor = line.equipment === '自动线'
|
||||||
|
? (isLight ? '#2563eb' : '#00d4ff')
|
||||||
|
: line.equipment === '半自动线'
|
||||||
|
? (isLight ? '#dc2626' : '#ff6b6b')
|
||||||
|
: (isLight ? '#d97706' : '#ffb340');
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<span style={{ width: 28, fontSize: 11, fontWeight: 700, color: isLight ? '#1e3a6e' : tokens.text, textAlign: 'right' }}>{line.name}</span>
|
||||||
|
<div style={{ flex: 1, height: 16, borderRadius: 4, background: isLight ? 'rgba(30,60,130,0.06)' : 'rgba(0,30,80,0.2)', position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', borderRadius: 4, width: `${pct}%`, background: `linear-gradient(90deg, ${barColor}cc, ${barColor})`, boxShadow: `0 0 8px ${barColor}40`, transition: 'width 0.6s ease' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ width: 72, fontSize: 11, fontWeight: 600, color: barColor, textAlign: 'right', whiteSpace: 'nowrap' }}>¥{fmt(line.totalCost)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 成本测算面板 ──────────────────────────────────────────────────────────────
|
||||||
|
function CostEstimation({ line, order, isLight, tokens }: {
|
||||||
|
line: ProductionLine;
|
||||||
|
order: Order;
|
||||||
|
isLight: boolean;
|
||||||
|
tokens: ReturnType<typeof useTheme>['tokens'];
|
||||||
|
}) {
|
||||||
|
const stdRow = order.rows.find(r => r.type === '原材料成本');
|
||||||
|
const materialCost = stdRow?.directMaterial ?? 0;
|
||||||
|
const mgmtExpense = stdRow?.mgmtExpense ?? 0;
|
||||||
|
const revenue = stdRow?.revenue ?? 0;
|
||||||
|
const qty = stdRow?.qty ?? 0;
|
||||||
|
|
||||||
|
const processingCost = line.totalCost;
|
||||||
|
const totalCost = materialCost + processingCost + mgmtExpense;
|
||||||
|
const grossProfit = revenue - totalCost;
|
||||||
|
const grossMargin = revenue > 0 ? grossProfit / revenue : 0;
|
||||||
|
const netProfit = grossProfit - mgmtExpense;
|
||||||
|
const netMargin = revenue > 0 ? netProfit / revenue : 0;
|
||||||
|
const unitCost = qty > 0 ? totalCost / qty : 0;
|
||||||
|
|
||||||
|
const issueCount = line.steps.filter(s => s.hasIssue).length;
|
||||||
|
const lossCost = line.steps.reduce((s, st) => s + Math.round(st.cost * st.defectRate), 0);
|
||||||
|
|
||||||
|
const selectColor = isLight ? '#2563eb' : '#00d4ff';
|
||||||
|
const successColor = isLight ? '#059669' : '#00e599';
|
||||||
|
const dangerColor = isLight ? '#dc2626' : '#ff6b6b';
|
||||||
|
const warningColor = isLight ? '#d97706' : '#ffb340';
|
||||||
|
|
||||||
|
// 与其他产线对比
|
||||||
|
const allLines = order.productionLines ?? [];
|
||||||
|
const bestLine = allLines.reduce((a, b) => a.totalCost < b.totalCost ? a : b, allLines[0]);
|
||||||
|
const diffVsBest = line.totalCost - bestLine.totalCost;
|
||||||
|
|
||||||
|
const metricCell = (label: string, value: string, color?: string, bold?: boolean) => (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '5px 0',
|
||||||
|
borderBottom: `1px solid ${isLight ? 'rgba(30,60,130,0.06)' : tokens.borderSub}`,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 11, color: isLight ? tokens.textSub : tokens.textMuted }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: bold ? 800 : 600, color: color || (isLight ? '#1e3a6e' : tokens.text), fontFamily: 'monospace' }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 10, borderRadius: 10,
|
||||||
|
border: `1.5px solid ${selectColor}40`,
|
||||||
|
background: isLight ? 'rgba(241,245,255,0.8)' : 'rgba(0,30,80,0.2)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
animation: 'card-in 0.3s ease',
|
||||||
|
}}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
background: isLight ? `${selectColor}08` : `${selectColor}15`,
|
||||||
|
borderBottom: `1px solid ${selectColor}25`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<rect x="1" y="1" width="12" height="12" rx="3" stroke={selectColor} strokeWidth="1.3"/>
|
||||||
|
<path d="M4 7 L6.5 9.5 L10 4.5" stroke={selectColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 800, color: selectColor }}>
|
||||||
|
成本测算 — {line.name}({line.equipment})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, padding: '2px 8px', borderRadius: 4,
|
||||||
|
background: `${selectColor}18`, color: selectColor, fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
点击产线可切换
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测算主体 */}
|
||||||
|
<div style={{ padding: '10px 14px', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
{/* 左列:成本构成 */}
|
||||||
|
<div style={{ flex: '1 1 200px', minWidth: 180 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: isLight ? tokens.textSub : tokens.textMuted, marginBottom: 6, letterSpacing: '0.04em' }}>成本构成</div>
|
||||||
|
{metricCell('原材料成本', `¥${fmt(materialCost)}`)}
|
||||||
|
{metricCell('加工成本', `¥${fmt(processingCost)}`)}
|
||||||
|
{metricCell('管理/销研费用', `¥${fmt(mgmtExpense)}`)}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '6px 0', marginTop: 2,
|
||||||
|
borderTop: `2px solid ${isLight ? 'rgba(30,60,130,0.12)' : tokens.border}`,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 800, color: isLight ? '#1e3a6e' : tokens.text }}>预估总成本</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 800, color: selectColor, fontFamily: 'monospace' }}>¥{fmt(totalCost)}</span>
|
||||||
|
</div>
|
||||||
|
{metricCell('单位成本', `¥${fmt(unitCost, 2)} / 件`)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中列:利润测算 */}
|
||||||
|
<div style={{ flex: '1 1 200px', minWidth: 180 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: isLight ? tokens.textSub : tokens.textMuted, marginBottom: 6, letterSpacing: '0.04em' }}>利润测算</div>
|
||||||
|
{metricCell('销售收入', `¥${fmt(revenue)}`)}
|
||||||
|
{metricCell('毛利', `¥${fmt(grossProfit)}`, grossProfit >= 0 ? successColor : dangerColor, true)}
|
||||||
|
{metricCell('毛利率', `${(grossMargin * 100).toFixed(2)}%`, grossMargin >= 0.1 ? successColor : grossMargin >= 0 ? warningColor : dangerColor, true)}
|
||||||
|
{metricCell('净利润(扣除管销研)', `¥${fmt(netProfit)}`, netProfit >= 0 ? successColor : dangerColor, true)}
|
||||||
|
{metricCell('净利率', `${(netMargin * 100).toFixed(2)}%`, netMargin >= 0.05 ? successColor : netMargin >= 0 ? warningColor : dangerColor, true)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右列:风险提示 */}
|
||||||
|
<div style={{ flex: '1 1 200px', minWidth: 180 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: isLight ? tokens.textSub : tokens.textMuted, marginBottom: 6, letterSpacing: '0.04em' }}>风险评估</div>
|
||||||
|
{metricCell('加工良率', `${(line.yieldRate * 100).toFixed(1)}%`,
|
||||||
|
line.yieldRate >= 0.97 ? successColor : line.yieldRate >= 0.95 ? warningColor : dangerColor, true)}
|
||||||
|
{metricCell('不良损耗成本', `¥${fmt(lossCost)}`, lossCost > 3000 ? dangerColor : undefined)}
|
||||||
|
{metricCell('工艺问题数', `${issueCount} 处`, issueCount > 2 ? dangerColor : issueCount > 0 ? warningColor : successColor, true)}
|
||||||
|
{metricCell(`vs ${bestLine.name}(${bestLine.equipment})`,
|
||||||
|
diffVsBest === 0 ? '成本最优' : `+¥${fmt(diffVsBest)}`,
|
||||||
|
diffVsBest === 0 ? successColor : dangerColor, true)}
|
||||||
|
{metricCell('总工时', `${line.totalHours}h`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 工序问题明细(如有) */}
|
||||||
|
{issueCount > 0 && (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderTop: `1px solid ${isLight ? 'rgba(30,60,130,0.06)' : tokens.borderSub}`,
|
||||||
|
background: isLight ? 'rgba(220,38,38,0.03)' : 'rgba(255,107,107,0.04)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: dangerColor, marginBottom: 4 }}>
|
||||||
|
⚠ 需关注的工艺问题
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{line.steps.filter(s => s.hasIssue).map(s => (
|
||||||
|
<div key={s.name} style={{
|
||||||
|
padding: '4px 8px', borderRadius: 6,
|
||||||
|
background: isLight ? 'rgba(220,38,38,0.06)' : 'rgba(255,107,107,0.1)',
|
||||||
|
border: `1px solid ${dangerColor}30`,
|
||||||
|
fontSize: 10,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 700, color: dangerColor }}>{s.name}</span>
|
||||||
|
<span style={{ color: isLight ? tokens.textSub : tokens.textMuted, marginLeft: 4 }}>
|
||||||
|
{s.issueDesc}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: dangerColor, marginLeft: 4, fontWeight: 600 }}>
|
||||||
|
不良{(s.defectRate * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 底部建议 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderTop: `1px dashed ${isLight ? 'rgba(30,60,130,0.1)' : tokens.borderSub}`,
|
||||||
|
fontSize: 10, lineHeight: 1.7,
|
||||||
|
color: isLight ? tokens.textSub : tokens.textMuted,
|
||||||
|
}}>
|
||||||
|
{diffVsBest === 0
|
||||||
|
? <span>✦ 推荐方案:{line.name}({line.equipment})为当前最优产线,加工成本最低,良率最高。</span>
|
||||||
|
: <span>✦ 建议:{line.name}较{bestLine.name}多投入 ¥{fmt(diffVsBest)}
|
||||||
|
{issueCount > 0 ? `,且有${issueCount}处工艺问题需解决` : ''}
|
||||||
|
。若切换至{bestLine.name}可降低成本并提升良率。</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 主组件 ────────────────────────────────────────────────────────────────────
|
||||||
|
export default function ProductionLineComparison({ lines, order }: Props) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!lines || lines.length === 0) return null;
|
||||||
|
|
||||||
|
const lowestCost = Math.min(...lines.map(l => l.totalCost));
|
||||||
|
const maxCost = Math.max(...lines.map(l => l.totalCost));
|
||||||
|
const totalIssues = lines.reduce((s, l) => s + l.steps.filter(st => st.hasIssue).length, 0);
|
||||||
|
const selectedLine = lines.find(l => l.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: tokens.textSub, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span>三、生产线成本对比</span>
|
||||||
|
{totalIssues > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600,
|
||||||
|
color: isLight ? '#dc2626' : '#ff6b6b',
|
||||||
|
padding: '1px 6px', borderRadius: 4,
|
||||||
|
background: isLight ? 'rgba(220,38,38,0.08)' : 'rgba(255,107,107,0.12)',
|
||||||
|
}}>
|
||||||
|
⚠ {totalIssues}处工艺异常
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 选择提示 */}
|
||||||
|
{!selectedLine && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center', fontSize: 11, marginBottom: 6,
|
||||||
|
color: isLight ? tokens.textSub : tokens.textMuted,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}>
|
||||||
|
点击下方产线卡片,可查看该产线的成本测算详情
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3条生产线卡片(可点击选择) */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 10, marginBottom: 10,
|
||||||
|
overflowX: 'auto', overflowY: 'hidden',
|
||||||
|
paddingBottom: 4,
|
||||||
|
}}>
|
||||||
|
{lines.map(line => (
|
||||||
|
<LineCard
|
||||||
|
key={line.id}
|
||||||
|
line={line}
|
||||||
|
isLight={isLight}
|
||||||
|
tokens={tokens}
|
||||||
|
isLowest={line.totalCost === lowestCost}
|
||||||
|
isSelected={line.id === selectedId}
|
||||||
|
onClick={() => setSelectedId(prev => prev === line.id ? null : line.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 成本对比柱状条 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${isLight ? 'rgba(30,60,130,0.12)' : tokens.border}`,
|
||||||
|
background: isLight ? 'rgba(241,245,255,0.6)' : 'rgba(0,20,60,0.15)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: isLight ? tokens.textSub : tokens.textMuted, marginBottom: 6 }}>
|
||||||
|
加工成本对比(不含原材料)
|
||||||
|
</div>
|
||||||
|
{lines.map(line => (
|
||||||
|
<CostBar key={line.id} line={line} maxCost={maxCost} isLight={isLight} tokens={tokens} />
|
||||||
|
))}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 6, fontSize: 10, lineHeight: 1.7,
|
||||||
|
color: isLight ? tokens.textSub : tokens.textMuted,
|
||||||
|
borderTop: `1px dashed ${isLight ? 'rgba(30,60,130,0.1)' : tokens.borderSub}`,
|
||||||
|
paddingTop: 6,
|
||||||
|
}}>
|
||||||
|
{(() => {
|
||||||
|
const best = lines.find(l => l.totalCost === lowestCost);
|
||||||
|
const worst = lines.find(l => l.totalCost === maxCost);
|
||||||
|
if (!best || !worst) return null;
|
||||||
|
const diff = maxCost - lowestCost;
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span style={{ color: isLight ? '#059669' : '#00e599', fontWeight: 600 }}>{best.name}({best.equipment})</span>
|
||||||
|
成本最优,较
|
||||||
|
<span style={{ color: isLight ? '#dc2626' : '#ff6b6b', fontWeight: 600 }}>{worst.name}({worst.equipment})</span>
|
||||||
|
节省 <span style={{ fontWeight: 700, color: isLight ? '#059669' : '#00e599' }}>¥{fmt(diff)}</span>
|
||||||
|
{totalIssues > 0 && `,其中${totalIssues}处工艺异常需重点排查`}。
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 选中产线的成本测算 */}
|
||||||
|
{selectedLine && (
|
||||||
|
<CostEstimation line={selectedLine} order={order} isLight={isLight} tokens={tokens} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { type Order } from '../data/orders';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface RightAIPanelProps {
|
||||||
|
selectedOrder: Order | null;
|
||||||
|
onExpandChange?: (expanded: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EQ_LIST = [
|
||||||
|
{ id: 'new', name: '自动线', costM: 1.0, timeM: 1.0, yieldM: 0.970, desc: '高精度数控产线,良率高、能耗低', color: '#00cc88', tags: ['数控', '高精度'], logo: 'cnc' },
|
||||||
|
{ id: 'old', name: '半自动线', costM: 1.35, timeM: 1.4, yieldM: 0.872, desc: '传统产线,维护成本高、良率偏低', color: '#ff8844', tags: ['传统', '高维护'], logo: 'lathe' },
|
||||||
|
{ id: 'mix', name: '混合设备', costM: 1.12, timeM: 1.17, yieldM: 0.934, desc: '新旧设备组合,性价比较好', color: '#4499ff', tags: ['组合', '性价比'], logo: 'hybrid' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function EqLogo({ type, color }: { type: string; color: string }) {
|
||||||
|
if (type === 'cnc') {
|
||||||
|
return (
|
||||||
|
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
|
||||||
|
<rect x="4" y="8" width="18" height="11" rx="2" stroke={color} strokeWidth="1.5"/>
|
||||||
|
<path d="M7 12h6M7 15h4" stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
<circle cx="18.5" cy="13.5" r="2.5" stroke={color} strokeWidth="1.2"/>
|
||||||
|
<path d="M20.5 15.5l3 3" stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
<path d="M8 8V6a2 2 0 012-2h6a2 2 0 012 2v2" stroke={color} strokeWidth="1.2"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === 'lathe') {
|
||||||
|
return (
|
||||||
|
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
|
||||||
|
<path d="M4 18h18v-4a2 2 0 00-2-2H6a2 2 0 00-2 2v4z" stroke={color} strokeWidth="1.5"/>
|
||||||
|
<circle cx="9" cy="14" r="2" stroke={color} strokeWidth="1.2"/>
|
||||||
|
<circle cx="17" cy="14" r="2" stroke={color} strokeWidth="1.2"/>
|
||||||
|
<path d="M7 14h10" stroke={color} strokeWidth="1" strokeDasharray="2 1"/>
|
||||||
|
<path d="M6 10V7a1 1 0 011-1h12a1 1 0 011 1v3" stroke={color} strokeWidth="1.2"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
|
||||||
|
<rect x="4" y="7" width="8" height="9" rx="1.5" stroke={color} strokeWidth="1.4"/>
|
||||||
|
<rect x="14" y="10" width="8" height="9" rx="1.5" stroke={color} strokeWidth="1.4"/>
|
||||||
|
<path d="M12 11.5h2" stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
<circle cx="8" cy="18.5" r="1.5" stroke={color} strokeWidth="1.2"/>
|
||||||
|
<circle cx="18" cy="21.5" r="1.5" stroke={color} strokeWidth="1.2"/>
|
||||||
|
<path d="M9.5 18.5l6.5 2.5" stroke={color} strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RightAIPanel({ selectedOrder: _selectedOrder, onExpandChange }: RightAIPanelProps) {
|
||||||
|
const { theme, tokens } = useTheme();
|
||||||
|
const isLight = theme === 'light';
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const handleExpand = () => {
|
||||||
|
const next = !expanded;
|
||||||
|
setExpanded(next);
|
||||||
|
onExpandChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const W = expanded ? 480 : 268;
|
||||||
|
const BULGE = 14;
|
||||||
|
|
||||||
|
const outlinePath = `M ${W},0 L 70,0 Q 0,0 0,80 C ${-BULGE},2000 ${-BULGE},8000 0,9920 L ${W},10000 Z`;
|
||||||
|
const strokePath = `M ${W},0.5 L 70,0.5 Q 0.5,0.5 0.5,80 C ${-BULGE + 0.5},2000 ${-BULGE + 0.5},8000 0.5,9920`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-right" style={{
|
||||||
|
width: W + BULGE, height: '100%', flexShrink: 0,
|
||||||
|
position: 'relative', overflow: 'visible', background: 'transparent',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
<svg style={{
|
||||||
|
position: 'absolute', top: 0, left: -BULGE,
|
||||||
|
width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, overflow: 'visible',
|
||||||
|
}} viewBox={`${-BULGE} 0 ${W + BULGE} 10000`} preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="rightAIBg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor={tokens.svgPanelFrom} />
|
||||||
|
<stop offset="100%" stopColor={tokens.svgPanelTo} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={outlinePath} fill="url(#rightAIBg)" />
|
||||||
|
<path d={strokePath} fill="none" stroke={tokens.svgStroke} strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<line x1={W - 0.5} y1="0" x2={W - 0.5} y2="10000" stroke={tokens.svgStroke.replace('0.45','0.15')} strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="70" cy="1" r="3" fill={tokens.svgDot} vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="0" cy="80" r="2.5" fill={tokens.svgStroke} vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={-BULGE} cy="5000" r="2" fill={tokens.svgStroke.replace('0.45','0.25')} vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1, marginLeft: BULGE, width: W,
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
background: 'transparent',
|
||||||
|
}}>
|
||||||
|
{/* 标题 */}
|
||||||
|
<div style={{ padding: '14px 14px 10px 12px', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<span style={{ width: 3, height: 16, borderRadius: 2, background: `linear-gradient(to bottom,${tokens.accent},${tokens.accent}88)`, display: 'inline-block' }} />
|
||||||
|
<span style={{ color: tokens.accent, fontSize: 19, fontWeight: 700, letterSpacing: '0.04em' }}>生产设备库</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleExpand} style={{
|
||||||
|
padding: '2px 8px', borderRadius: 7,
|
||||||
|
border: `1px solid ${expanded ? tokens.success + '60' : tokens.border}`,
|
||||||
|
background: expanded ? tokens.successBg : 'transparent',
|
||||||
|
color: expanded ? tokens.success : tokens.textMuted,
|
||||||
|
fontSize: 12, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{expanded ? '收起' : '展开'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备卡片(始终可见,可拖拽到中间) */}
|
||||||
|
<div style={{ padding: '6px 12px 8px', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10, color: tokens.textMuted, marginBottom: 1 }}>
|
||||||
|
<span style={{ fontSize: 11 }}>⇢</span>
|
||||||
|
<span>拖拽设备到中间「智能模拟实际成本」区域</span>
|
||||||
|
</div>
|
||||||
|
{EQ_LIST.map((eq, idx) => (
|
||||||
|
<div
|
||||||
|
key={eq.id}
|
||||||
|
draggable
|
||||||
|
title={`${eq.name}:${eq.desc}`}
|
||||||
|
onDragStart={e => {
|
||||||
|
e.dataTransfer.setData('application/x-equipment-json', JSON.stringify(eq));
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
e.currentTarget.style.opacity = '0.6';
|
||||||
|
e.currentTarget.style.transform = 'scale(0.98)';
|
||||||
|
}}
|
||||||
|
onDragEnd={e => {
|
||||||
|
e.currentTarget.style.opacity = '1';
|
||||||
|
e.currentTarget.style.transform = 'none';
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 10px 10px 12px', borderRadius: 10,
|
||||||
|
border: `1px solid ${eq.color}44`,
|
||||||
|
background: `${eq.color}08`,
|
||||||
|
cursor: 'grab', transition: 'all 0.2s ease',
|
||||||
|
animation: `notice-in 0.3s ${idx * 0.06}s both`,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.borderColor = eq.color;
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
e.currentTarget.style.boxShadow = `0 5px 16px ${eq.color}28`;
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.borderColor = `${eq.color}44`;
|
||||||
|
e.currentTarget.style.transform = 'none';
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 设备 LOGO */}
|
||||||
|
<div style={{
|
||||||
|
width: 42, height: 42, borderRadius: 10, flexShrink: 0,
|
||||||
|
background: `${eq.color}15`,
|
||||||
|
border: `1px solid ${eq.color}35`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<EqLogo type={eq.logo} color={eq.color} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备信息 */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: tokens.text, whiteSpace: 'nowrap', flexShrink: 0 }}>{eq.name}</span>
|
||||||
|
<div style={{ display: 'flex', gap: 3 }}>
|
||||||
|
{eq.tags.map(tag => (
|
||||||
|
<span key={tag} style={{
|
||||||
|
padding: '1px 4px', borderRadius: 3, fontSize: 9, fontWeight: 600,
|
||||||
|
background: `${eq.color}15`, color: eq.color, border: `1px solid ${eq.color}25`,
|
||||||
|
}}>{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, fontSize: 10, color: isLight ? tokens.textSub : tokens.textMuted }}>
|
||||||
|
<span>良率 <b style={{ color: eq.color }}>{(eq.yieldM * 100).toFixed(1)}%</b></span>
|
||||||
|
<span>成本 <b>×{eq.costM}</b></span>
|
||||||
|
<span>工时 <b>×{eq.timeM}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 拖拽抓手提示 */}
|
||||||
|
<div style={{
|
||||||
|
width: 18, height: 32, borderRadius: 4,
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 3,
|
||||||
|
color: `${eq.color}70`,
|
||||||
|
}}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<circle cx="2.5" cy="2.5" r="1" fill="currentColor"/>
|
||||||
|
<circle cx="9.5" cy="2.5" r="1" fill="currentColor"/>
|
||||||
|
<circle cx="2.5" cy="6" r="1" fill="currentColor"/>
|
||||||
|
<circle cx="9.5" cy="6" r="1" fill="currentColor"/>
|
||||||
|
<circle cx="2.5" cy="9.5" r="1" fill="currentColor"/>
|
||||||
|
<circle cx="9.5" cy="9.5" r="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 实际交付报告生成逻辑 ──────────────────────────────────────────────────────
|
||||||
|
// 注:以下类型与函数原为内部辅助逻辑,整合到 ai-platform 时保留以备未来调用
|
||||||
|
export type RT = { danger: string; warning: string; success: string; accent: string; textSub: string };
|
||||||
|
export interface CostKPI { label: string; value: string; color: string; }
|
||||||
|
export interface LossItem { label: string; value: string; desc: string; color: string; barWidth: number; }
|
||||||
|
export interface Suggestion { text: string; color: string; }
|
||||||
|
export interface ProcessIssue { step: string; line: string; desc: string; }
|
||||||
|
export interface DeliveryReport { costKPIs: CostKPI[]; lossItems: LossItem[]; suggestions: Suggestion[]; processIssues: ProcessIssue[]; }
|
||||||
|
|
||||||
|
export function generateDeliveryReport(order: Order, t: RT): DeliveryReport {
|
||||||
|
const stdRow = order.rows.find(r => r.type === '原材料成本')!;
|
||||||
|
const purRow = order.rows.find(r => r.type === '直接人工成本')!;
|
||||||
|
const useRow = order.rows.find(r => r.type === '管理成本')!;
|
||||||
|
const mc = (v: number) => Math.round(v).toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
// ═══ 1. 实际成本 ═══
|
||||||
|
const costKPIs: CostKPI[] = [
|
||||||
|
{ label: '实际总成本', value: `¥${mc(useRow.totalCost)}`, color: t.accent },
|
||||||
|
{ label: '销售收入', value: `¥${mc(useRow.revenue)}`, color: t.textSub },
|
||||||
|
{ label: '净利润', value: `¥${mc(useRow.netProfit)}`, color: useRow.netProfit >= 0 ? t.success : t.danger },
|
||||||
|
{ label: '净利率', value: `${(useRow.netMargin * 100).toFixed(1)}%`, color: useRow.netMargin >= 0.05 ? t.success : useRow.netMargin >= 0 ? t.warning : t.danger },
|
||||||
|
{ label: '毛利率', value: `${(useRow.grossMargin * 100).toFixed(1)}%`, color: useRow.grossMargin >= 0.1 ? t.success : t.warning },
|
||||||
|
{ label: '单位成本', value: `¥${(useRow.totalCost / useRow.qty).toFixed(2)}`, color: t.accent },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ═══ 2. 成本损耗分析 ═══
|
||||||
|
const lossItems: LossItem[] = [];
|
||||||
|
const matDiff = useRow.directMaterial - stdRow.directMaterial;
|
||||||
|
const matPct = matDiff / stdRow.directMaterial;
|
||||||
|
lossItems.push({
|
||||||
|
label: '材料损耗', value: `${matDiff >= 0 ? '+' : ''}¥${mc(Math.abs(matDiff))}`, color: matDiff > 0 ? t.danger : t.success,
|
||||||
|
desc: matDiff > 0 ? `材料领用超标准 ${(matPct * 100).toFixed(1)}%,重点核查领料损耗与库存差异` : `材料领用节省 ${Math.abs(matPct * 100).toFixed(1)}%,退料或工艺优化效果显著`,
|
||||||
|
barWidth: Math.min(100, Math.abs(matPct) * 500),
|
||||||
|
});
|
||||||
|
const labDiff = useRow.directLabor - stdRow.directLabor;
|
||||||
|
const labPct = labDiff / stdRow.directLabor;
|
||||||
|
lossItems.push({
|
||||||
|
label: '人工差异', value: `${labDiff >= 0 ? '+' : ''}¥${mc(Math.abs(labDiff))}`, color: labDiff > 0 ? t.danger : t.success,
|
||||||
|
desc: labDiff > 0 ? `人工成本超标准 ${(labPct * 100).toFixed(1)}%,核查工时记录与生产效率` : `人工节省 ${Math.abs(labPct * 100).toFixed(1)}%,生产效率优于标准`,
|
||||||
|
barWidth: Math.min(100, Math.abs(labPct) * 300),
|
||||||
|
});
|
||||||
|
const purMatDiff = useRow.directMaterial - purRow.directMaterial;
|
||||||
|
lossItems.push({
|
||||||
|
label: '采购vs领用差异', value: `${purMatDiff >= 0 ? '+' : ''}¥${mc(Math.abs(purMatDiff))}`, color: Math.abs(purMatDiff) > 5000 ? t.warning : t.success,
|
||||||
|
desc: purMatDiff > 0 ? '领用超出采购,可能存在补领或库存核算差异' : '领用低于采购,确认退料是否已入账',
|
||||||
|
barWidth: Math.min(100, Math.abs(purMatDiff) / 1000),
|
||||||
|
});
|
||||||
|
const mfgPct = useRow.manufacturingCost / useRow.revenue;
|
||||||
|
lossItems.push({
|
||||||
|
label: '制造费用占比', value: `${(mfgPct * 100).toFixed(1)}%`, color: mfgPct > 0.2 ? t.warning : t.success,
|
||||||
|
desc: `制造费用 ¥${mc(useRow.manufacturingCost)},占销售收入 ${(mfgPct * 100).toFixed(1)}%`,
|
||||||
|
barWidth: mfgPct * 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══ 3. 改善建议 ═══
|
||||||
|
const suggestions: Suggestion[] = [];
|
||||||
|
if (matDiff > 0) {
|
||||||
|
suggestions.push({ text: `材料损耗偏高(+¥${mc(matDiff)}),建议核查各工序领料单与实际损耗率,重点管控车加工和磨加工环节的材料浪费。`, color: t.danger });
|
||||||
|
} else {
|
||||||
|
suggestions.push({ text: `材料控制良好(节省 ¥${mc(Math.abs(matDiff))}),建议沉淀当前工艺参数作为标准参考。`, color: t.success });
|
||||||
|
}
|
||||||
|
if (labDiff > 0) {
|
||||||
|
suggestions.push({ text: `人工成本超标 ¥${mc(labDiff)}(+${(labPct * 100).toFixed(1)}%),建议排查加班工时、设备故障停机时间及人员配置合理性。`, color: t.warning });
|
||||||
|
} else {
|
||||||
|
suggestions.push({ text: `人工效率优异,较标准节省 ¥${mc(Math.abs(labDiff))},建议评估是否可更新标准工时。`, color: t.success });
|
||||||
|
}
|
||||||
|
if (Math.abs(purMatDiff) > 3000) {
|
||||||
|
suggestions.push({ text: purMatDiff > 0 ? '领用量超出采购量,建议核对领料单与入库记录,排查是否存在补领或库存核算差异。' : '实际领用节约,确认退料流程是否已入账,避免账实不符。', color: t.accent });
|
||||||
|
}
|
||||||
|
if (useRow.netMargin < 0.05) {
|
||||||
|
suggestions.push({ text: `净利率偏低(${(useRow.netMargin * 100).toFixed(1)}%),建议下期报价上调至少 ${((0.08 - useRow.netMargin) * 100).toFixed(1)} 个百分点。`, color: t.warning });
|
||||||
|
} else {
|
||||||
|
suggestions.push({ text: '当前利润率健康,可作为同品类订单报价参考基准。', color: t.success });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 工艺问题(来自生产线数据) ═══
|
||||||
|
const processIssues: ProcessIssue[] = [];
|
||||||
|
if (order.productionLines) {
|
||||||
|
order.productionLines.forEach(line => {
|
||||||
|
line.steps.filter(s => s.hasIssue).forEach(s => {
|
||||||
|
processIssues.push({ step: s.name, line: line.name, desc: s.issueDesc || '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { costKPIs, lossItems, suggestions, processIssues };
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { TRANSLATORS, orgTypeConfig, type Translator } from '../data/translators';
|
||||||
|
import TechIcon from './TechIcons';
|
||||||
|
|
||||||
|
interface RightTranslatorPanelProps {
|
||||||
|
onTranslatorSelect: (t: Translator) => void;
|
||||||
|
selectedId: number | null;
|
||||||
|
onExpandChange?: (expanded: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabilityConfig = {
|
||||||
|
available: { label: '可接洽', color: '#00e599' },
|
||||||
|
busy: { label: '忙碌中', color: '#ffb340' },
|
||||||
|
offline: { label: '离线', color: 'rgba(255,255,255,0.3)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'vendor' | 'university' | 'enterprise';
|
||||||
|
|
||||||
|
export default function RightTranslatorPanel({ onTranslatorSelect, selectedId, onExpandChange }: RightTranslatorPanelProps) {
|
||||||
|
const [filter, setFilter] = useState<FilterType>('all');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const filtered = filter === 'all' ? TRANSLATORS : TRANSLATORS.filter(t => t.orgType === filter);
|
||||||
|
const availableCount = TRANSLATORS.filter(t => t.availability === 'available').length;
|
||||||
|
|
||||||
|
const handleExpand = () => {
|
||||||
|
const next = !expanded;
|
||||||
|
setExpanded(next);
|
||||||
|
onExpandChange?.(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BULGE = 28;
|
||||||
|
const W = expanded ? 460 : 260;
|
||||||
|
|
||||||
|
const outlinePath = `M ${W},0 L 70,0 Q 0,0 0,80 C ${-BULGE},2000 ${-BULGE},8000 0,9920 L ${W},10000 Z`;
|
||||||
|
const strokePath = `M ${W},0.5 L 70,0.5 Q 0.5,0.5 0.5,80 C ${-BULGE + 0.5},2000 ${-BULGE + 0.5},8000 0.5,9920`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-animate-right" style={{
|
||||||
|
width: W + BULGE, height: '100%', flexShrink: 0,
|
||||||
|
position: 'relative', overflow: 'visible', background: 'transparent',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
<svg style={{
|
||||||
|
position: 'absolute', top: 0, left: -BULGE,
|
||||||
|
width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, overflow: 'visible',
|
||||||
|
}} viewBox={`${-BULGE} 0 ${W + BULGE} 10000`} preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="rightTranslatorBg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgba(4,28,75,0.88)" />
|
||||||
|
<stop offset="100%" stopColor="rgba(2,14,48,0.78)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={outlinePath} fill="url(#rightTranslatorBg)" />
|
||||||
|
<path d={strokePath} fill="none" stroke="rgba(0,212,255,0.45)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<line x1={W - 0.5} y1="0" x2={W - 0.5} y2="10000" stroke="rgba(0,212,255,0.15)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="70" cy="1" r="3" fill="rgba(0,212,255,0.9)" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx="0" cy="80" r="2.5" fill="rgba(0,212,255,0.6)" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle cx={-BULGE} cy="5000" r="2" fill="rgba(0,212,255,0.25)" vectorEffect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1, marginLeft: BULGE, width: W,
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
{/* 标题区 */}
|
||||||
|
<div style={{ padding: '14px 14px 10px 12px', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
<span style={{ width: 3, height: 16, borderRadius: 2, background: 'linear-gradient(to bottom,#7b61ff,#4433cc)', display: 'inline-block' }} />
|
||||||
|
<span style={{ color: '#c8b4ff', fontSize: 16, fontWeight: 600, letterSpacing: '0.04em' }}>AI翻译官</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 8, background: 'rgba(0,229,153,0.15)', color: '#00e599', fontSize: 12, fontWeight: 700 }}>
|
||||||
|
{availableCount}可接洽
|
||||||
|
</span>
|
||||||
|
<button onClick={handleExpand} style={{
|
||||||
|
padding: '2px 8px', borderRadius: 7,
|
||||||
|
border: `1px solid ${expanded ? 'rgba(123,97,255,0.4)' : 'rgba(123,97,255,0.2)'}`,
|
||||||
|
background: expanded ? 'rgba(123,97,255,0.1)' : 'transparent',
|
||||||
|
color: expanded ? '#7b61ff' : 'rgba(255,255,255,0.5)',
|
||||||
|
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{expanded ? <><TechIcon name="collapse" color="currentColor" size={13} /> 收起</> : <><TechIcon name="expand" color="currentColor" size={13} /> 展开</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 类型筛选 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 10 }}>
|
||||||
|
{(['all', 'vendor', 'university', 'enterprise'] as FilterType[]).map(f => {
|
||||||
|
const isActive = filter === f;
|
||||||
|
const cfg = f !== 'all' ? orgTypeConfig[f] : null;
|
||||||
|
return (
|
||||||
|
<button key={f} onClick={() => setFilter(f)} style={{
|
||||||
|
flex: 1, padding: '4px 0', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||||
|
border: `1px solid ${isActive ? (cfg?.color + '50' || 'rgba(123,97,255,0.4)') : 'rgba(255,255,255,0.1)'}`,
|
||||||
|
background: isActive ? (cfg?.bg || 'rgba(123,97,255,0.12)') : 'transparent',
|
||||||
|
color: isActive ? (cfg?.color || '#7b61ff') : 'rgba(255,255,255,0.5)',
|
||||||
|
}}>
|
||||||
|
{f === 'all' ? '全部' : cfg!.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginTop: 8, alignItems: 'center' }}>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.4)', fontSize: 11 }}>接洽率</span>
|
||||||
|
<div style={{ flex: 1, height: 2, borderRadius: 1, background: 'rgba(123,97,255,0.1)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${(availableCount / TRANSLATORS.length) * 100}%`, height: '100%', background: 'linear-gradient(to right, #7b61ff, #00e599)', borderRadius: 1 }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#7b61ff', fontSize: 11 }}>{availableCount}/{TRANSLATORS.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 1, background: 'rgba(123,97,255,0.15)', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 翻译官列表 */}
|
||||||
|
<div className="space-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px 8px 12px' }}>
|
||||||
|
{filtered.map((translator, idx) => {
|
||||||
|
const isSelected = selectedId === translator.id;
|
||||||
|
const orgCfg = orgTypeConfig[translator.orgType];
|
||||||
|
const availCfg = availabilityConfig[translator.availability];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={translator.id}
|
||||||
|
onClick={() => onTranslatorSelect(translator)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 10px', borderRadius: 10, marginBottom: 7,
|
||||||
|
border: `1px solid ${isSelected ? translator.color + '60' : 'rgba(123,97,255,0.15)'}`,
|
||||||
|
background: isSelected
|
||||||
|
? `linear-gradient(135deg, ${translator.color}12 0%, ${translator.accentColor}08 100%)`
|
||||||
|
: 'rgba(10,5,40,0.4)',
|
||||||
|
cursor: 'pointer', transition: 'all 0.18s ease',
|
||||||
|
animation: `notice-in 0.3s ${idx * 0.06}s both`,
|
||||||
|
boxShadow: isSelected ? `0 0 14px ${translator.color}20` : 'none',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(30,20,70,0.6)'; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(10,5,40,0.4)'; }}
|
||||||
|
>
|
||||||
|
{/* 顶行:头像 + 姓名 + 状态 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: `linear-gradient(135deg, ${translator.color}22 0%, ${translator.accentColor}12 100%)`,
|
||||||
|
border: `1px solid ${translator.color}40`,
|
||||||
|
color: translator.color, fontSize: 15, fontWeight: 700,
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
boxShadow: isSelected ? `0 0 12px ${translator.color}30` : 'none',
|
||||||
|
}}>
|
||||||
|
{isSelected && (
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: '-100%', width: '60%', height: '100%', background: `linear-gradient(to right, transparent, ${translator.color}25, transparent)`, animation: 'space-border-scan 1.5s linear infinite' }} />
|
||||||
|
)}
|
||||||
|
{translator.avatar}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: isSelected ? translator.color : '#c8e4ff', fontSize: 14, fontWeight: 700, textShadow: isSelected ? `0 0 8px ${translator.color}80` : 'none' }}>
|
||||||
|
{translator.name}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: availCfg.color, boxShadow: `0 0 5px ${availCfg.color}`, display: 'inline-block', animation: translator.availability === 'available' ? 'space-pulse 2s ease-in-out infinite' : 'none' }} />
|
||||||
|
<span style={{ color: availCfg.color, fontSize: 11 }}>{availCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11 }}>{translator.title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 机构 + 类型标签 */}
|
||||||
|
<div style={{ paddingLeft: 44, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: 12, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{translator.organization}</span>
|
||||||
|
<span style={{ padding: '1px 6px', borderRadius: 4, background: orgCfg.bg, color: orgCfg.color, fontSize: 10, fontWeight: 600, flexShrink: 0 }}>{orgCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 简介 */}
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, margin: '6px 0 5px', paddingLeft: 44, lineHeight: 1.55, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{translator.bio}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 指标:评分 + 案例 */}
|
||||||
|
<div style={{ paddingLeft: 44, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||||
|
<TechIcon name="star" color="#ffb340" size={12} />
|
||||||
|
<span style={{ color: '#ffb340', fontSize: 13, fontWeight: 700 }}>{translator.rating}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '2px 6px', borderRadius: 4, background: `${translator.color}10`, border: `1px solid ${translator.color}25` }}>
|
||||||
|
<span style={{ color: translator.color, fontSize: 12, fontWeight: 700 }}>{translator.cases}</span>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11 }}> 案例</span>
|
||||||
|
</div>
|
||||||
|
{/* 专长 */}
|
||||||
|
<div style={{ display: 'flex', gap: 3, flex: 1, overflow: 'hidden' }}>
|
||||||
|
{translator.expertise.slice(0, 2).map(e => (
|
||||||
|
<span key={e} style={{ padding: '1px 5px', borderRadius: 3, background: `${translator.color}10`, color: translator.color, fontSize: 10, whiteSpace: 'nowrap' }}>{e}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开态:认证信息 */}
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ marginTop: 8, paddingLeft: 44, animation: 'notice-in 0.2s both' }}>
|
||||||
|
<div style={{ padding: '6px 10px', borderRadius: 7, background: 'rgba(0,10,30,0.45)', border: `1px solid ${translator.color}20` }}>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: 11, marginBottom: 4 }}>专业认证</div>
|
||||||
|
{translator.certifications.map(c => (
|
||||||
|
<div key={c} style={{ color: 'rgba(255,255,255,0.75)', fontSize: 12, lineHeight: 1.5 }}>· {c}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { Theme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface Orb {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
r: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
color: string;
|
||||||
|
glowAlpha: number;
|
||||||
|
pulsePhase: number;
|
||||||
|
pulseSpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StarBackground({ theme = 'dark' }: { theme?: Theme }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
// 生成星星
|
||||||
|
const stars = Array.from({ length: 220 }, () => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
r: Math.random() * 1.2 + 0.2,
|
||||||
|
opacity: Math.random() * 0.6 + 0.2,
|
||||||
|
speed: Math.random() * 0.015 + 0.005,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 深空云雾粒子
|
||||||
|
const nebula = Array.from({ length: 6 }, (_, i) => ({
|
||||||
|
x: (i / 6) * canvas.width + Math.random() * 200 - 100,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
r: Math.random() * 180 + 120,
|
||||||
|
color: ['rgba(0,80,160,', 'rgba(40,0,120,', 'rgba(0,60,100,', 'rgba(20,0,80,', 'rgba(0,100,120,', 'rgba(60,0,100,'][i],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 大小球体 — 飞来飞去的光球
|
||||||
|
const darkOrbColors = [
|
||||||
|
'rgba(220,240,255,',
|
||||||
|
'rgba(200,230,255,',
|
||||||
|
'rgba(255,255,255,',
|
||||||
|
'rgba(230,240,255,',
|
||||||
|
'rgba(0,180,255,',
|
||||||
|
'rgba(120,100,255,',
|
||||||
|
'rgba(0,229,153,',
|
||||||
|
'rgba(0,212,255,',
|
||||||
|
];
|
||||||
|
const lightOrbColors = [
|
||||||
|
'rgba(160,185,230,',
|
||||||
|
'rgba(180,200,240,',
|
||||||
|
'rgba(200,215,245,',
|
||||||
|
'rgba(140,170,220,',
|
||||||
|
'rgba(100,140,210,',
|
||||||
|
'rgba(170,190,235,',
|
||||||
|
'rgba(120,160,220,',
|
||||||
|
'rgba(190,210,245,',
|
||||||
|
];
|
||||||
|
const orbColors = theme === 'dark' ? darkOrbColors : lightOrbColors;
|
||||||
|
const orbGlowBase = theme === 'dark' ? 0.025 : 0.07;
|
||||||
|
const orbs: Orb[] = Array.from({ length: 64 }, (_, i) => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
r: i < 24 ? (Math.random() * 30 + 35) : (Math.random() * 12 + 10),
|
||||||
|
vx: (Math.random() - 0.5) * 2.2,
|
||||||
|
vy: (Math.random() - 0.5) * 1.6,
|
||||||
|
color: orbColors[i % orbColors.length],
|
||||||
|
glowAlpha: Math.random() * orbGlowBase + orbGlowBase * 0.4,
|
||||||
|
pulsePhase: Math.random() * Math.PI * 2,
|
||||||
|
pulseSpeed: Math.random() * 0.012 + 0.006,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
let animId: number;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
frame++;
|
||||||
|
const W = canvas.width;
|
||||||
|
const H = canvas.height;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// 背景渐变
|
||||||
|
const grad = ctx.createRadialGradient(W * 0.5, H * 0.5, 0, W * 0.5, H * 0.5, W * 0.7);
|
||||||
|
if (theme === 'dark') {
|
||||||
|
grad.addColorStop(0, 'rgba(0,15,40,1)');
|
||||||
|
grad.addColorStop(0.5, 'rgba(0,8,25,1)');
|
||||||
|
grad.addColorStop(1, 'rgba(0,4,15,1)');
|
||||||
|
} else {
|
||||||
|
grad.addColorStop(0, 'rgba(248,250,255,1)');
|
||||||
|
grad.addColorStop(0.5, 'rgba(243,246,255,1)');
|
||||||
|
grad.addColorStop(1, 'rgba(236,241,255,1)');
|
||||||
|
}
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// 星云雾
|
||||||
|
nebula.forEach(n => {
|
||||||
|
const g = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, n.r);
|
||||||
|
const nebulaAlpha = theme === 'dark' ? '0.06' : '0.04';
|
||||||
|
g.addColorStop(0, n.color + nebulaAlpha + ')');
|
||||||
|
g.addColorStop(1, n.color + '0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ★ 飞行球体
|
||||||
|
orbs.forEach(orb => {
|
||||||
|
// 更新位置
|
||||||
|
orb.x += orb.vx;
|
||||||
|
orb.y += orb.vy;
|
||||||
|
|
||||||
|
// 边界反弹(加上球体半径,让球不会从中心出界)
|
||||||
|
if (orb.x < -orb.r) orb.x = W + orb.r;
|
||||||
|
if (orb.x > W + orb.r) orb.x = -orb.r;
|
||||||
|
if (orb.y < -orb.r) orb.y = H + orb.r;
|
||||||
|
if (orb.y > H + orb.r) orb.y = -orb.r;
|
||||||
|
|
||||||
|
// 脉冲效果
|
||||||
|
const pulse = 0.7 + 0.3 * Math.sin(frame * orb.pulseSpeed + orb.pulsePhase);
|
||||||
|
const alpha = orb.glowAlpha * pulse;
|
||||||
|
|
||||||
|
// 外层大光晕
|
||||||
|
const outerR = orb.r * 1.5;
|
||||||
|
const outerGrad = ctx.createRadialGradient(orb.x, orb.y, 0, orb.x, orb.y, outerR);
|
||||||
|
outerGrad.addColorStop(0, orb.color + (alpha * 0.9).toFixed(3) + ')');
|
||||||
|
outerGrad.addColorStop(0.5, orb.color + (alpha * 0.3).toFixed(3) + ')');
|
||||||
|
outerGrad.addColorStop(1, orb.color + '0)');
|
||||||
|
ctx.fillStyle = outerGrad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(orb.x, orb.y, outerR, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 内层球体
|
||||||
|
const innerGrad = ctx.createRadialGradient(
|
||||||
|
orb.x - orb.r * 0.2, orb.y - orb.r * 0.2, 0,
|
||||||
|
orb.x, orb.y, orb.r
|
||||||
|
);
|
||||||
|
innerGrad.addColorStop(0, orb.color + (alpha * 2.0).toFixed(3) + ')');
|
||||||
|
innerGrad.addColorStop(0.6, orb.color + (alpha * 0.8).toFixed(3) + ')');
|
||||||
|
innerGrad.addColorStop(1, orb.color + '0)');
|
||||||
|
ctx.fillStyle = innerGrad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(orb.x, orb.y, orb.r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 高光点
|
||||||
|
const hlR = orb.r * 0.25;
|
||||||
|
const hlGrad = ctx.createRadialGradient(
|
||||||
|
orb.x - orb.r * 0.25, orb.y - orb.r * 0.25, 0,
|
||||||
|
orb.x - orb.r * 0.25, orb.y - orb.r * 0.25, hlR
|
||||||
|
);
|
||||||
|
hlGrad.addColorStop(0, 'rgba(255,255,255,' + (alpha * 4).toFixed(3) + ')');
|
||||||
|
hlGrad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||||
|
ctx.fillStyle = hlGrad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(orb.x - orb.r * 0.25, orb.y - orb.r * 0.25, hlR, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 星星
|
||||||
|
stars.forEach(s => {
|
||||||
|
const twinkle = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(frame * s.speed + s.phase));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = theme === 'dark'
|
||||||
|
? `rgba(180,220,255,${s.opacity * twinkle})`
|
||||||
|
: `rgba(100,140,200,${s.opacity * twinkle * 0.5})`;
|
||||||
|
ctx.fill();
|
||||||
|
// 亮星加光晕
|
||||||
|
if (s.r > 1.0) {
|
||||||
|
const halo = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.r * 4);
|
||||||
|
halo.addColorStop(0, theme === 'dark'
|
||||||
|
? `rgba(100,200,255,${0.2 * twinkle})`
|
||||||
|
: `rgba(80,130,220,${0.08 * twinkle})`);
|
||||||
|
halo.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
ctx.fillStyle = halo;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(s.x, s.y, s.r * 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 水平光线
|
||||||
|
if (frame % 300 === 0) {
|
||||||
|
const y = Math.random() * H;
|
||||||
|
const lineGrad = ctx.createLinearGradient(0, y, W, y);
|
||||||
|
lineGrad.addColorStop(0, 'rgba(0,212,255,0)');
|
||||||
|
lineGrad.addColorStop(0.5, 'rgba(0,212,255,0.06)');
|
||||||
|
lineGrad.addColorStop(1, 'rgba(0,212,255,0)');
|
||||||
|
ctx.strokeStyle = lineGrad;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(W, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0, left: 0,
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { CSSProperties, ReactElement } from 'react';
|
||||||
|
|
||||||
|
export type TechIconName =
|
||||||
|
| 'chat'
|
||||||
|
| 'maturity'
|
||||||
|
| 'translator'
|
||||||
|
| 'roadmap'
|
||||||
|
| 'coupon'
|
||||||
|
| 'compute'
|
||||||
|
| 'data'
|
||||||
|
| 'tool'
|
||||||
|
| 'app'
|
||||||
|
| 'edge'
|
||||||
|
| 'policy'
|
||||||
|
| 'analytics'
|
||||||
|
| 'target'
|
||||||
|
| 'lab'
|
||||||
|
| 'deploy'
|
||||||
|
| 'location'
|
||||||
|
| 'factory'
|
||||||
|
| 'enterprise'
|
||||||
|
| 'pin'
|
||||||
|
| 'bot'
|
||||||
|
| 'user'
|
||||||
|
| 'check'
|
||||||
|
| 'close'
|
||||||
|
| 'star'
|
||||||
|
| 'calendar'
|
||||||
|
| 'alert'
|
||||||
|
| 'idea'
|
||||||
|
| 'scenario'
|
||||||
|
| 'empty'
|
||||||
|
| 'expand'
|
||||||
|
| 'collapse';
|
||||||
|
|
||||||
|
interface TechIconProps {
|
||||||
|
name: TechIconName;
|
||||||
|
color?: string;
|
||||||
|
size?: number;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TechIcon({ name, color = '#00d4ff', size = 18, style }: TechIconProps) {
|
||||||
|
const common = {
|
||||||
|
fill: 'none',
|
||||||
|
stroke: color,
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
strokeLinecap: 'round' as const,
|
||||||
|
strokeLinejoin: 'round' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const glyphs: Record<TechIconName, ReactElement> = {
|
||||||
|
chat: <><path d="M5 6.5h14v8.5H9l-4 3v-11.5z" /><path d="M8 10h8M8 13h5" /></>,
|
||||||
|
maturity: <><path d="M12 3l7 4v6c0 4.5-3 7.4-7 8-4-.6-7-3.5-7-8V7l7-4z" /><path d="M8.5 12h2l1.2-3 2 6 1.2-3h1.6" /></>,
|
||||||
|
translator: <><circle cx="12" cy="8" r="3.2" /><path d="M5.5 20c.8-3.8 3-5.8 6.5-5.8s5.7 2 6.5 5.8" /><path d="M18.5 7.5h2.2M19.6 6.4v2.2" /></>,
|
||||||
|
roadmap: <><path d="M4 18V6l5-2 6 2 5-2v12l-5 2-6-2-5 2z" /><path d="M9 4v12M15 6v12" /><circle cx="12" cy="11" r="1.8" /></>,
|
||||||
|
coupon: <><path d="M4 8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2.2a2.2 2.2 0 0 0 0 3.6V16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2.2a2.2 2.2 0 0 0 0-3.6V8z" /><path d="M9 9h6M9 15h6M12 6v12" /></>,
|
||||||
|
compute: <><rect x="5" y="5" width="14" height="14" rx="2" /><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3" /><path d="M10 14l2-4 2 4" /></>,
|
||||||
|
data: <><ellipse cx="12" cy="6" rx="6" ry="2.5" /><path d="M6 6v6c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5V6" /><path d="M6 12v6c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-6" /></>,
|
||||||
|
tool: <><path d="M14.5 5.5a4 4 0 0 0 4 4L9 19l-4-4 9.5-9.5z" /><path d="M6 16l2 2" /></>,
|
||||||
|
app: <><path d="M12 3l3 6 6 1-4.5 4.2 1 6-5.5-3-5.5 3 1-6L3 10l6-1 3-6z" /></>,
|
||||||
|
edge: <><rect x="5" y="5" width="14" height="14" rx="2" /><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3" /></>,
|
||||||
|
policy: <><path d="M7 3h8l4 4v14H7z" /><path d="M15 3v5h4M10 12h6M10 16h6" /></>,
|
||||||
|
analytics: <><path d="M4 19h16" /><path d="M7 16v-5M12 16V7M17 16v-8" /><path d="M6 8l4-3 4 2 4-4" /></>,
|
||||||
|
target: <><circle cx="12" cy="12" r="8" /><circle cx="12" cy="12" r="4" /><path d="M12 2v4M12 18v4M2 12h4M18 12h4" /></>,
|
||||||
|
lab: <><path d="M9 3h6M10 3v5l-5 9a3 3 0 0 0 2.6 4.5h8.8A3 3 0 0 0 19 17l-5-9V3" /><path d="M8 16h8" /></>,
|
||||||
|
deploy: <><path d="M12 3v12" /><path d="M7 8l5-5 5 5" /><path d="M5 15v4h14v-4" /></>,
|
||||||
|
location: <><path d="M12 21s6-5.1 6-11a6 6 0 0 0-12 0c0 5.9 6 11 6 11z" /><circle cx="12" cy="10" r="2" /></>,
|
||||||
|
factory: <><path d="M4 20V9l5 3V9l5 3V6h6v14z" /><path d="M8 16h2M13 16h2M17 16h2" /></>,
|
||||||
|
enterprise: <><rect x="5" y="4" width="14" height="17" rx="1.5" /><path d="M9 8h1M14 8h1M9 12h1M14 12h1M9 16h1M14 16h1" /></>,
|
||||||
|
pin: <><path d="M12 3l5 5-3 1.5V14l-2 2-2-2V9.5L7 8l5-5z" /><path d="M12 16v5" /></>,
|
||||||
|
bot: <><rect x="5" y="8" width="14" height="10" rx="3" /><path d="M12 5v3M9 13h.01M15 13h.01M9 17h6" /><circle cx="12" cy="4" r="1" /></>,
|
||||||
|
user: <><circle cx="12" cy="8" r="3.2" /><path d="M5.5 20c.8-3.7 3-5.5 6.5-5.5s5.7 1.8 6.5 5.5" /></>,
|
||||||
|
check: <><path d="M5 12l4 4 10-10" /></>,
|
||||||
|
close: <><path d="M6 6l12 12M18 6L6 18" /></>,
|
||||||
|
star: <><path d="M12 3l2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.2l-5.6 3 1.1-6.2L3 9.6l6.2-.9L12 3z" /></>,
|
||||||
|
calendar: <><rect x="5" y="5" width="14" height="15" rx="2" /><path d="M8 3v4M16 3v4M5 10h14" /></>,
|
||||||
|
alert: <><path d="M12 4l9 16H3l9-16z" /><path d="M12 9v5M12 17h.01" /></>,
|
||||||
|
idea: <><path d="M9 18h6M10 21h4" /><path d="M8 12a4 4 0 1 1 8 0c0 2-1.2 3-2.2 4h-3.6C9.2 15 8 14 8 12z" /></>,
|
||||||
|
scenario: <><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M4.9 4.9L7 7M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1L7 17M17 7l2.1-2.1" /></>,
|
||||||
|
empty: <><circle cx="12" cy="12" r="7" /><path d="M12 5v14M5 12h14" /><path d="M7.5 7.5l9 9M16.5 7.5l-9 9" opacity="0.55" /></>,
|
||||||
|
expand: <><path d="M8 5h-3v3M16 5h3v3M5 16v3h3M19 16v3h-3" /><path d="M5 5l5 5M19 5l-5 5M5 19l5-5M19 19l-5-5" /></>,
|
||||||
|
collapse: <><path d="M10 5v5H5M14 5v5h5M10 19v-5H5M14 19v-5h5" /></>,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" style={{ display: 'inline-block', verticalAlign: 'middle', ...style }} {...common}>
|
||||||
|
{glyphs[name]}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* DataSourceContext — 数据源模式管理 (order-cost-analysis 应用)
|
||||||
|
* 管理 'static'(TS本地文件)与 'api'(后端引擎)之间的切换
|
||||||
|
* 持久化到 localStorage,API 不可达时自动回退到 static
|
||||||
|
*/
|
||||||
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type DataSourceMode = 'static' | 'api';
|
||||||
|
export type ApiStatus = 'unknown' | 'online' | 'offline';
|
||||||
|
|
||||||
|
interface DataSourceContextValue {
|
||||||
|
mode: DataSourceMode;
|
||||||
|
setMode: (mode: DataSourceMode) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
apiStatus: ApiStatus;
|
||||||
|
isFallback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataSourceContext = createContext<DataSourceContextValue | null>(null);
|
||||||
|
const STORAGE_KEY = 'ocDataSourceMode';
|
||||||
|
const HEALTH_INTERVAL = 30_000;
|
||||||
|
|
||||||
|
export function DataSourceProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [mode, setModeState] = useState<DataSourceMode>(() => {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return saved === 'api' ? 'api' : 'static';
|
||||||
|
});
|
||||||
|
const [apiStatus, setApiStatus] = useState<ApiStatus>('unknown');
|
||||||
|
const [isFallback, setIsFallback] = useState(false);
|
||||||
|
|
||||||
|
const checkHealth = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 3000);
|
||||||
|
const res = await fetch('/health', { signal: ctrl.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
const online = res.ok;
|
||||||
|
setApiStatus(online ? 'online' : 'offline');
|
||||||
|
if (online && isFallback) setIsFallback(false);
|
||||||
|
return online;
|
||||||
|
} catch {
|
||||||
|
setApiStatus('offline');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [isFallback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkHealth();
|
||||||
|
const id = setInterval(checkHealth, HEALTH_INTERVAL);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [checkHealth]);
|
||||||
|
|
||||||
|
const setMode = useCallback((newMode: DataSourceMode) => {
|
||||||
|
setModeState(newMode);
|
||||||
|
localStorage.setItem(STORAGE_KEY, newMode);
|
||||||
|
if (newMode === 'static') {
|
||||||
|
setIsFallback(false);
|
||||||
|
} else {
|
||||||
|
checkHealth().then(online => {
|
||||||
|
if (!online) setIsFallback(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [checkHealth]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setMode(mode === 'static' ? 'api' : 'static');
|
||||||
|
}, [mode, setMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataSourceContext.Provider value={{ mode, setMode, toggle, apiStatus, isFallback }}>
|
||||||
|
{children}
|
||||||
|
</DataSourceContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDataSource(): DataSourceContextValue {
|
||||||
|
const ctx = useContext(DataSourceContext);
|
||||||
|
if (!ctx) throw new Error('useDataSource must be used within DataSourceProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { createContext, useContext, useState } from 'react';
|
||||||
|
|
||||||
|
export type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
|
// ── Token 定义 ─────────────────────────────────────────────────────────────
|
||||||
|
export interface ThemeTokens {
|
||||||
|
// 背景
|
||||||
|
bodyBg: string;
|
||||||
|
panelBg: string; // 面板主背景
|
||||||
|
panelBgAlt: string; // 面板次背景(偶数行等)
|
||||||
|
centerBg: string; // 中间工作区背景
|
||||||
|
// 边框
|
||||||
|
border: string; // 主边框
|
||||||
|
borderSub: string; // 次边框(更浅)
|
||||||
|
borderAccent: string; // 强调边框(accent色调)
|
||||||
|
// 文字
|
||||||
|
text: string; // 主文字
|
||||||
|
textSub: string; // 次文字
|
||||||
|
textMuted: string; // 弱提示文字
|
||||||
|
textInverse: string; // 反色文字(用于深色背景上的浅文字)
|
||||||
|
// 强调色
|
||||||
|
accent: string; // 主强调色 (深色:cyan, 浅色:navy blue)
|
||||||
|
accentGlow: string; // 强调色发光
|
||||||
|
accentBg: string; // 强调色背景
|
||||||
|
// 功能色
|
||||||
|
success: string;
|
||||||
|
successBg: string;
|
||||||
|
warning: string;
|
||||||
|
warningBg: string;
|
||||||
|
danger: string;
|
||||||
|
dangerBg: string;
|
||||||
|
// 特殊组件
|
||||||
|
headerScanLine: string; // header 扫光线
|
||||||
|
kpiCardBg: string; // KPI卡片背景
|
||||||
|
kpiCardBorder: string;
|
||||||
|
tableHeaderBg: string; // 表格 header 背景
|
||||||
|
tableRowHover: string; // 表格行 hover
|
||||||
|
tableRowAlt: string; // 表格偶数行
|
||||||
|
scrollThumb: string; // 滚动条
|
||||||
|
// SVG面板渐变色(左右弧形面板)
|
||||||
|
svgPanelFrom: string;
|
||||||
|
svgPanelTo: string;
|
||||||
|
svgStroke: string;
|
||||||
|
svgDot: string;
|
||||||
|
// 底部弧形
|
||||||
|
bottomArcFill: string;
|
||||||
|
bottomBg: string;
|
||||||
|
// 标题装饰
|
||||||
|
titleBarColor: string;
|
||||||
|
titleGlow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const darkTokens: ThemeTokens = {
|
||||||
|
bodyBg: '#000a1e',
|
||||||
|
panelBg: 'rgba(0,20,50,0.35)',
|
||||||
|
panelBgAlt: 'rgba(0,20,60,0.2)',
|
||||||
|
centerBg: 'rgba(0,15,45,0.55)',
|
||||||
|
border: 'rgba(0,212,255,0.12)',
|
||||||
|
borderSub: 'rgba(0,212,255,0.08)',
|
||||||
|
borderAccent: 'rgba(0,212,255,0.28)',
|
||||||
|
text: '#e8f4ff',
|
||||||
|
textSub: '#a8d8ff',
|
||||||
|
textMuted: 'rgba(255,255,255,0.5)',
|
||||||
|
textInverse: '#e8f4ff',
|
||||||
|
accent: '#00d4ff',
|
||||||
|
accentGlow: 'rgba(0,212,255,0.7)',
|
||||||
|
accentBg: 'rgba(0,212,255,0.06)',
|
||||||
|
success: '#00e599',
|
||||||
|
successBg: 'rgba(0,229,153,0.08)',
|
||||||
|
warning: '#ffb340',
|
||||||
|
warningBg: 'rgba(255,179,64,0.12)',
|
||||||
|
danger: '#ff6b6b',
|
||||||
|
dangerBg: 'rgba(255,107,107,0.08)',
|
||||||
|
headerScanLine: 'linear-gradient(90deg, transparent 0%, rgba(0,212,255,0.3) 20%, rgba(0,212,255,0.9) 50%, rgba(0,212,255,0.3) 80%, transparent 100%)',
|
||||||
|
kpiCardBg: 'rgba(0,212,255,0.05)',
|
||||||
|
kpiCardBorder: 'rgba(0,212,255,0.1)',
|
||||||
|
tableHeaderBg: 'rgba(0,40,100,0.7)',
|
||||||
|
tableRowHover: 'rgba(0,50,100,0.2)',
|
||||||
|
tableRowAlt: 'rgba(0,20,60,0.2)',
|
||||||
|
scrollThumb: 'rgba(0,212,255,0.25)',
|
||||||
|
svgPanelFrom: 'rgba(4,28,75,0.42)',
|
||||||
|
svgPanelTo: 'rgba(2,14,48,0.32)',
|
||||||
|
svgStroke: 'rgba(0,212,255,0.45)',
|
||||||
|
svgDot: 'rgba(0,212,255,0.9)',
|
||||||
|
bottomArcFill: 'rgba(0,15,50,0.42)',
|
||||||
|
bottomBg: 'linear-gradient(to bottom, rgba(0,15,50,0.42), rgba(0,8,28,0.52))',
|
||||||
|
titleBarColor: '#a8d8ff',
|
||||||
|
titleGlow: '0 0 24px rgba(0,212,255,0.7), 0 0 48px rgba(0,212,255,0.3)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightTokens: ThemeTokens = {
|
||||||
|
bodyBg: '#eef1f7',
|
||||||
|
panelBg: 'rgba(255,255,255,0.97)',
|
||||||
|
panelBgAlt: 'rgba(248,250,255,0.98)',
|
||||||
|
centerBg: 'linear-gradient(160deg, rgba(255,255,255,0.78) 0%, rgba(240,246,255,0.72) 50%, rgba(232,241,255,0.75) 100%)',
|
||||||
|
border: 'rgba(30,58,110,0.14)',
|
||||||
|
borderSub: 'rgba(30,58,110,0.08)',
|
||||||
|
borderAccent: 'rgba(26,86,219,0.28)',
|
||||||
|
text: '#111827',
|
||||||
|
textSub: '#1e3a6e',
|
||||||
|
textMuted: '#6b7280',
|
||||||
|
textInverse: '#ffffff',
|
||||||
|
accent: '#1a56db',
|
||||||
|
accentGlow: 'rgba(26,86,219,0.35)',
|
||||||
|
accentBg: 'rgba(26,86,219,0.06)',
|
||||||
|
success: '#047857',
|
||||||
|
successBg: 'rgba(4,120,87,0.07)',
|
||||||
|
warning: '#b45309',
|
||||||
|
warningBg: 'rgba(180,83,9,0.07)',
|
||||||
|
danger: '#b91c1c',
|
||||||
|
dangerBg: 'rgba(185,28,28,0.06)',
|
||||||
|
headerScanLine: 'linear-gradient(90deg, transparent 0%, rgba(26,86,219,0.15) 20%, rgba(26,86,219,0.5) 50%, rgba(26,86,219,0.15) 80%, transparent 100%)',
|
||||||
|
kpiCardBg: 'rgba(26,86,219,0.04)',
|
||||||
|
kpiCardBorder: 'rgba(26,86,219,0.12)',
|
||||||
|
tableHeaderBg: 'rgba(226,232,248,0.9)',
|
||||||
|
tableRowHover: 'rgba(210,222,255,0.4)',
|
||||||
|
tableRowAlt: 'rgba(241,245,255,0.75)',
|
||||||
|
scrollThumb: 'rgba(26,86,219,0.2)',
|
||||||
|
svgPanelFrom: 'rgba(238,243,255,0.97)',
|
||||||
|
svgPanelTo: 'rgba(225,235,255,0.95)',
|
||||||
|
svgStroke: 'rgba(26,86,219,0.35)',
|
||||||
|
svgDot: 'rgba(26,86,219,0.75)',
|
||||||
|
bottomArcFill: 'rgba(238,243,255,0.97)',
|
||||||
|
bottomBg: 'linear-gradient(to bottom, rgba(238,243,255,0.97), rgba(225,235,252,0.99))',
|
||||||
|
titleBarColor: '#111827',
|
||||||
|
titleGlow: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Context ────────────────────────────────────────────────────────────────
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: Theme;
|
||||||
|
tokens: ThemeTokens;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue>({
|
||||||
|
theme: 'dark',
|
||||||
|
tokens: darkTokens,
|
||||||
|
toggleTheme: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>('dark');
|
||||||
|
const tokens = theme === 'dark' ? darkTokens : lightTokens;
|
||||||
|
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, tokens, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// 优惠券数据
|
||||||
|
export type CouponType = 'compute' | 'data' | 'consulting' | 'tool' | 'app';
|
||||||
|
|
||||||
|
export interface Coupon {
|
||||||
|
id: string;
|
||||||
|
type: CouponType;
|
||||||
|
title: string;
|
||||||
|
provider: string;
|
||||||
|
value: string;
|
||||||
|
condition: string;
|
||||||
|
expiry: string;
|
||||||
|
remaining: number;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
desc: string;
|
||||||
|
tags: string[];
|
||||||
|
claimed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COUPONS: Coupon[] = [
|
||||||
|
{
|
||||||
|
id: 'C001',
|
||||||
|
type: 'compute',
|
||||||
|
title: '算力代金券',
|
||||||
|
provider: '温州市人工智能局 · 算力生态伙伴',
|
||||||
|
value: '¥5000',
|
||||||
|
condition: '温州AI成熟度L1-L3制造企业专享',
|
||||||
|
expiry: '2026-12-31',
|
||||||
|
remaining: 87,
|
||||||
|
color: '#00d4ff',
|
||||||
|
icon: 'compute',
|
||||||
|
desc: '用于AI模型训练和推理的云计算资源,适用于工业视觉、预测维护等AI应用开发',
|
||||||
|
tags: ['模型训练', '推理部署', '弹性算力'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C002',
|
||||||
|
type: 'data',
|
||||||
|
title: '语料数据券',
|
||||||
|
provider: '温州市人工智能局 · 数据服务伙伴',
|
||||||
|
value: '¥3000',
|
||||||
|
condition: '制造业数据标注服务',
|
||||||
|
expiry: '2026-09-30',
|
||||||
|
remaining: 45,
|
||||||
|
color: '#7b61ff',
|
||||||
|
icon: 'data',
|
||||||
|
desc: '用于工业图像标注、设备数据清洗整理、工艺参数结构化等数据服务',
|
||||||
|
tags: ['图像标注', '数据清洗', '特征工程'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C003',
|
||||||
|
type: 'consulting',
|
||||||
|
title: 'AI翻译官咨询券',
|
||||||
|
provider: '温州市人工智能局',
|
||||||
|
value: '8课时',
|
||||||
|
condition: '首次对接免费诊断',
|
||||||
|
expiry: '2026-12-31',
|
||||||
|
remaining: 120,
|
||||||
|
color: '#ff8c42',
|
||||||
|
icon: 'translator',
|
||||||
|
desc: '由认证AI翻译官提供1对1需求解析服务,包含温州企业AI成熟度评估报告和初步方案建议',
|
||||||
|
tags: ['需求解析', '成熟度评估', '方案建议'],
|
||||||
|
claimed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C004',
|
||||||
|
type: 'tool',
|
||||||
|
title: 'AI工具试用券',
|
||||||
|
provider: '温州市人工智能局 · 大模型工具伙伴',
|
||||||
|
value: '3个月',
|
||||||
|
condition: '大模型API免费调用',
|
||||||
|
expiry: '2026-08-31',
|
||||||
|
remaining: 200,
|
||||||
|
color: '#00e599',
|
||||||
|
icon: 'tool',
|
||||||
|
desc: '免费试用工业大模型API,适用于工艺知识问答、设备故障诊断、生产报告生成等场景',
|
||||||
|
tags: ['大模型API', '工业知识库', '智能问答'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C005',
|
||||||
|
type: 'app',
|
||||||
|
title: 'AI应用部署券',
|
||||||
|
provider: '温州市AI+制造专项',
|
||||||
|
value: '¥10000',
|
||||||
|
condition: '完成AI落地规划认定',
|
||||||
|
expiry: '2026-12-31',
|
||||||
|
remaining: 30,
|
||||||
|
color: '#ff6b9d',
|
||||||
|
icon: 'app',
|
||||||
|
desc: '专项支持温州市制造企业AI应用系统部署实施费用,需通过AI落地规划评审,适用于重点AI+制造场景',
|
||||||
|
tags: ['落地补贴', '专项资金', '实施补贴'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C006',
|
||||||
|
type: 'compute',
|
||||||
|
title: '边缘计算资源券',
|
||||||
|
provider: '温州市人工智能局 · 边缘计算伙伴',
|
||||||
|
value: '¥8000',
|
||||||
|
condition: '工厂边缘AI部署项目',
|
||||||
|
expiry: '2026-10-31',
|
||||||
|
remaining: 52,
|
||||||
|
color: '#4fc3f7',
|
||||||
|
icon: 'edge',
|
||||||
|
desc: '用于工厂现场边缘计算节点部署,支持工业视觉、实时质检等需要低延迟的AI应用',
|
||||||
|
tags: ['边缘计算', '实时推理', '低延迟'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const couponTypeConfig: Record<CouponType, { label: string; color: string; bg: string }> = {
|
||||||
|
compute: { label: '算力券', color: '#00d4ff', bg: 'rgba(0,212,255,0.1)' },
|
||||||
|
data: { label: '语料券', color: '#7b61ff', bg: 'rgba(123,97,255,0.1)' },
|
||||||
|
consulting: { label: '咨询券', color: '#ff8c42', bg: 'rgba(255,140,66,0.1)' },
|
||||||
|
tool: { label: '工具券', color: '#00e599', bg: 'rgba(0,229,153,0.1)' },
|
||||||
|
app: { label: '应用券', color: '#ff6b9d', bg: 'rgba(255,107,157,0.1)' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
// 温州市人工智能赋能新型工业化需求数据
|
||||||
|
export type MaturityLevel = 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
|
||||||
|
export type DemandStatus = 'new' | 'matching' | 'matched' | 'pilot' | 'deployed';
|
||||||
|
export type Industry =
|
||||||
|
| '智能电气'
|
||||||
|
| '鞋业服装'
|
||||||
|
| '汽车零部件'
|
||||||
|
| '工业泵阀'
|
||||||
|
| '智能装备'
|
||||||
|
| '新能源'
|
||||||
|
| '新材料'
|
||||||
|
| '智能物联'
|
||||||
|
| '眼镜'
|
||||||
|
| '食品农产品'
|
||||||
|
| '低空经济';
|
||||||
|
|
||||||
|
export interface AIDemand {
|
||||||
|
id: string;
|
||||||
|
company: string;
|
||||||
|
industry: Industry;
|
||||||
|
region: string;
|
||||||
|
size: '小微' | '中型' | '大型' | '超大';
|
||||||
|
maturityLevel: MaturityLevel;
|
||||||
|
maturityScore: number;
|
||||||
|
status: DemandStatus;
|
||||||
|
painPoint: string;
|
||||||
|
scenario: string;
|
||||||
|
department: string;
|
||||||
|
dataReadiness: number;
|
||||||
|
budget: string;
|
||||||
|
timeline: string;
|
||||||
|
tags: string[];
|
||||||
|
urgency: 'high' | 'medium' | 'low';
|
||||||
|
submittedAt: string;
|
||||||
|
businessDesc?: string;
|
||||||
|
technicalDesc?: string;
|
||||||
|
suggestedSolution?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEMANDS: AIDemand[] = [
|
||||||
|
{
|
||||||
|
id: 'WZ001', company: '瑞浦兰钧能源股份有限公司', industry: '新能源', region: '温州市·龙湾区', size: '超大', maturityLevel: 'L4', maturityScore: 72, status: 'matching',
|
||||||
|
painPoint: '现场设备维护与售后问题诊断依赖人工经验,组织运营分析能力需要AI增强',
|
||||||
|
scenario: '人工智能分析诊断', department: '设备运维 / 运营管理', dataReadiness: 78, budget: '300-800万', timeline: '6-12个月',
|
||||||
|
tags: ['智能穿戴', '故障诊断', '运营分析', '新能源电池'], urgency: 'high', submittedAt: '2026-06-12',
|
||||||
|
businessDesc: '企业是温州新能源电池龙头,储能锂离子电池国内外市场份额较高。现场设备维护、售后服务和运营诊断场景复杂,人员需要在现场快速判断故障原因、处置优先级与组织协同路径。',
|
||||||
|
technicalDesc: '需求来自现场智能穿戴设备、数字化基座和运营数据的融合应用。重点是把设备状态、人员作业、售后记录、运营指标打通,让AI可以识别故障类型、生成诊断建议,并提供核心运营分析。',
|
||||||
|
suggestedSolution: '按照温州新能源参考指引,建议建设“智能穿戴采集 + 设备故障AI诊断 + 经营分析智能体”。先做高频故障知识库和现场诊断助手,再扩展到全域组织运营分析看板。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ002', company: '浙江长江汽车电子有限公司', industry: '汽车零部件', region: '温州市·龙湾区', size: '超大', maturityLevel: 'L4', maturityScore: 76, status: 'pilot',
|
||||||
|
painPoint: '作业指导书、预测维护和智能排产三类场景并行,需统一AI能力底座',
|
||||||
|
scenario: 'AI作业指导书与智能排产', department: '工艺部 / 设备部 / 计划部', dataReadiness: 82, budget: '500-1000万', timeline: '9-18个月',
|
||||||
|
tags: ['作业指导书', '预测维护', '智能排产', 'MES对接'], urgency: 'high', submittedAt: '2026-06-12',
|
||||||
|
businessDesc: '企业每年作业指导书新版与变更量超过15000份,复杂工艺逻辑、多步骤联动和特殊环境操作要求多;同时设备预测维护、智能排产也需要与汽车电子工艺体系联动。',
|
||||||
|
technicalDesc: '现有SAP、MES、设备台账、历史运维数据、BOM和工艺路线库可作为基础。关键挑战是多系统数据标准不一,AI生成内容必须可落地、无歧义,并支撑排产交期达成率和资源利用率提升。',
|
||||||
|
suggestedSolution: '建议搭建“汽车电子工艺知识库 + 作业指导书生成智能体 + APS智能排产引擎 + 设备维护模型”。优先上线作业指导书智能生成与审核,再接入MES形成执行闭环。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ003', company: '金帝集团股份有限公司', industry: '鞋业服装', region: '温州市·龙湾区', size: '大型', maturityLevel: 'L3', maturityScore: 61, status: 'matching',
|
||||||
|
painPoint: '鞋服裁片型体和缺陷识别对准确率、速度和MES回写要求极高',
|
||||||
|
scenario: 'AI生产优化与视觉质检', department: '生产部 / 质量部', dataReadiness: 66, budget: '200-500万', timeline: '6-12个月',
|
||||||
|
tags: ['鞋业裁片识别', 'AI质检', 'MES上传', '小单快反'], urgency: 'high', submittedAt: '2026-06-11',
|
||||||
|
businessDesc: '企业作为温州鞋业龙头,需要实时识别常见裁片型体和部位,缺陷分类准确率目标超过99.9%,并自动生成工序汇报单上传MES,支撑鞋服小单快反生产。',
|
||||||
|
technicalDesc: '场景符合温州鞋业服装参考指引中的AI质量检测与生产进度智能管控。需要建设高质量裁片图像样本库、缺陷标准库,并通过WebAPI打通MES。',
|
||||||
|
suggestedSolution: '建议采用“边缘视觉检测工位 + 裁片/缺陷多任务模型 + MES接口服务”。先从Top 1000高频裁片和典型缺陷开始训练,逐步扩展到万级型体库。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ004', company: '浙江晨泰科技股份有限公司', industry: '智能电气', region: '温州市·龙湾区', size: '大型', maturityLevel: 'L3', maturityScore: 58, status: 'new',
|
||||||
|
painPoint: '智能电表与充电桩生产排产约束复杂,异常后需要快速重排',
|
||||||
|
scenario: 'AI计划排产', department: '计划部 / 生产部', dataReadiness: 63, budget: '200-500万', timeline: '6-9个月',
|
||||||
|
tags: ['智能排产', 'what-if仿真', '多目标优化', '智能电气'], urgency: 'high', submittedAt: '2026-06-11',
|
||||||
|
businessDesc: '企业要求系统支持交期、产能均衡、换线最小化等多目标优化,并处理设备能力、物料齐套、人员技能、模具限制等五类以上约束,异常发生后10分钟内生成新排产方案。',
|
||||||
|
technicalDesc: '该需求高度契合温州智能电气共性场景“AI计划排产”。核心在于整合订单、BOM、设备、人员、模具与库存数据,建立可解释的约束规划和仿真能力。',
|
||||||
|
suggestedSolution: '建议建设“智能电气APS + what-if仿真台 + 异常扰动重排引擎”。先做关键产线的约束建模,再扩展到多工厂协同排产。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ005', company: '温州瑞立科密汽车电子有限公司', industry: '汽车零部件', region: '温州市·龙湾区', size: '大型', maturityLevel: 'L3', maturityScore: 64, status: 'matching',
|
||||||
|
painPoint: '铝合金压铸件与主动安全系统部件缺陷需要亚秒级视觉检测',
|
||||||
|
scenario: 'AI质量控制', department: '质量部 / 制造工程部', dataReadiness: 70, budget: '200-500万', timeline: '3-6个月',
|
||||||
|
tags: ['毛刺识别', '裂纹检测', '压铸缺陷', '边缘AI'], urgency: 'high', submittedAt: '2026-06-10',
|
||||||
|
businessDesc: '企业需要准确识别毛刺、形变、裂纹、划痕等缺陷,综合检测准确率目标超过99.8%,图像采集、分析与出结果总时长小于1秒/件。',
|
||||||
|
technicalDesc: '该需求对应汽车零部件AI智能检测场景。核心难点是微小缺陷样本不足、节拍约束强、现场光照与工件姿态波动大。',
|
||||||
|
suggestedSolution: '建议采用“高帧率工业相机 + 边缘推理盒 + 小样本缺陷增强训练”。先建立缺陷样本闭环机制,再与MES/质量系统联动形成质量追溯。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ006', company: '维都利阀门有限公司', industry: '工业泵阀', region: '温州市·龙湾区', size: '中型', maturityLevel: 'L2', maturityScore: 49, status: 'new',
|
||||||
|
painPoint: '特材阀门核心产品缺陷识别需要匹配产线节拍并保持高准确率',
|
||||||
|
scenario: '泵阀AI质量检测', department: '质量部 / 生产部', dataReadiness: 52, budget: '100-300万', timeline: '6-9个月',
|
||||||
|
tags: ['特材阀门', '视觉检测', '密封面缺陷', '质量追溯'], urgency: 'medium', submittedAt: '2026-06-10',
|
||||||
|
businessDesc: '企业是温州特材合金阀门制造标杆,需求是实时识别至少5种核心产品缺陷,缺陷分类准确率超过99.5%,并满足产线节拍。',
|
||||||
|
technicalDesc: '泵阀产品存在材质反光、结构复杂、缺陷形态多样等特点,适合参考工业泵阀指引中的AI质量检测和质量精准追溯。',
|
||||||
|
suggestedSolution: '建议建设“泵阀缺陷视觉模型 + 多角度成像工位 + 质量追溯数据库”。优先覆盖密封面、外观裂纹、砂眼、装配完整性等关键检测项。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ007', company: '浙江乔顿服饰股份有限公司', industry: '鞋业服装', region: '温州市·龙湾区', size: '大型', maturityLevel: 'L3', maturityScore: 60, status: 'matched',
|
||||||
|
painPoint: '服装设计企划、虚拟样衣和工艺资料生成效率不足,跨部门共识难',
|
||||||
|
scenario: '产品研发全流程AI赋能', department: '设计部 / 工艺部 / 企划部', dataReadiness: 62, budget: '300-800万', timeline: '9-18个月',
|
||||||
|
tags: ['AIGC设计', '虚拟样衣', '工艺资料生成', '服装研发'], urgency: 'medium', submittedAt: '2026-06-09',
|
||||||
|
businessDesc: '项目聚焦服装产品研发全流程AI赋能,解决设计企划共识难、样衣评审打样成本高且周期长、工艺技术资料生成低效易错等问题。',
|
||||||
|
technicalDesc: '需求对应鞋业服装指引中的产品AI设计、产品虚拟验证和质量智能分析。需要把趋势内容、版型、面料、工艺模板和历史款式数据结构化。',
|
||||||
|
suggestedSolution: '建议建设“AIGC企划智能体 + 高保真虚拟样衣 + 工艺资料自动生成系统”。先从职业装和高端商务男装核心款式做模板沉淀。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ008', company: '温州市安通阀门有限公司', industry: '工业泵阀', region: '温州市·龙湾区', size: '中型', maturityLevel: 'L2', maturityScore: 46, status: 'new',
|
||||||
|
painPoint: '阀门企业多系统数据割裂,管理者缺少自然语言数据问答能力',
|
||||||
|
scenario: '企业级AI数据问答助手', department: '经营管理 / 信息化部', dataReadiness: 48, budget: '80-200万', timeline: '3-6个月',
|
||||||
|
tags: ['ChatBI', '数据中台', 'Text2SQL', '经营决策'], urgency: 'medium', submittedAt: '2026-06-09',
|
||||||
|
businessDesc: '企业希望建设数据中台和AI数据问答助手,深度融合ChatBI、Headless BI、Text2SQL、Agent记忆管理和自然语言API,为决策者实时提供关键数据清单和图表。',
|
||||||
|
technicalDesc: '该场景对应泵阀行业“数智精益管理”和“智能经营决策”。关键工作是梳理ERP、MES、CRM、仓储等系统指标口径,建立语义层。',
|
||||||
|
suggestedSolution: '建议先做“经营指标语义层 + 自然语言查询 + 图表生成”。围绕订单、库存、交付、质量、成本五类核心指标构建第一期。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ009', company: '浙江瑞强汽车部件有限公司', industry: '汽车零部件', region: '温州市·龙湾区', size: '中型', maturityLevel: 'L3', maturityScore: 55, status: 'matching',
|
||||||
|
painPoint: '紧固件生产质量检测环节多,外侧裂、头宽、牙外径等异常需要AI识别',
|
||||||
|
scenario: '视觉检测软件', department: '质量部 / 车间管理', dataReadiness: 58, budget: '100-300万', timeline: '6-9个月',
|
||||||
|
tags: ['紧固件', '涡流硬度测试', '外观检测', '质量数据'], urgency: 'medium', submittedAt: '2026-06-08',
|
||||||
|
businessDesc: '企业拥有完整紧固件生产工艺链,需要通过AI算法视觉检测控制产品质量,覆盖IBG涡流硬度测试、外侧裂、头宽、表面检查、牙外径等异常检测。',
|
||||||
|
technicalDesc: '该场景属于汽车零部件AI智能检测和质量精准追溯。需要把视觉、涡流、尺寸测量和生产批次数据关联起来。',
|
||||||
|
suggestedSolution: '建议建设“紧固件多模态检测平台”,包含教导、取相、上线检测、测试与侦错模块,并对接质量追溯数据库。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ010', company: '浙江戈美其鞋业有限公司', industry: '鞋业服装', region: '温州市·鹿城区', size: '大型', maturityLevel: 'L2', maturityScore: 44, status: 'new',
|
||||||
|
painPoint: '全渠道客服、库存调拨和销售预测依赖人工,门店规模大导致协同压力高',
|
||||||
|
scenario: '鞋业全渠道AI智能体', department: '客服中心 / 商品运营 / 供应链', dataReadiness: 50, budget: '150-400万', timeline: '6-12个月',
|
||||||
|
tags: ['AI客服', '库存调拨', '销售预测', '门店运营'], urgency: 'medium', submittedAt: '2026-06-08',
|
||||||
|
businessDesc: '企业拥有1000余家终端门店,希望构建全渠道智能客服、全局库存视图、最优调拨建议和销售预测模型,提升库存周转和客户服务能力。',
|
||||||
|
technicalDesc: '场景对应鞋业服装指引中的智能经营决策、采购计划优化协同和产品精准营销。需要融合门店销售、库存、季节、活动、社交趋势和天气数据。',
|
||||||
|
suggestedSolution: '建议分三步建设:先上线AI客服知识库,再做库存调拨推荐,最后训练销售预测与爆款趋势模型。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ011', company: '浙江炜冈科技股份有限公司', industry: '智能装备', region: '温州市·平阳县', size: '大型', maturityLevel: 'L3', maturityScore: 57, status: 'matched',
|
||||||
|
painPoint: '售后、内部知识和零件图片资料分散,客户与员工查询效率低',
|
||||||
|
scenario: 'AI企业知识库智能体', department: '售后服务 / 技术部 / 采购部', dataReadiness: 60, budget: '150-400万', timeline: '6-12个月',
|
||||||
|
tags: ['售后知识库', '内部知识库', '图片识别', '标签印刷设备'], urgency: 'medium', submittedAt: '2026-06-07',
|
||||||
|
businessDesc: '企业需要建设面向客户的售后知识库、面向内部员工的知识库,以及“产品零件图片-图号/料号”AI图像识别知识库,实现拍照自动识别零件编码。',
|
||||||
|
technicalDesc: '该需求符合智能装备指引中的远程智能运维服务和产品AI设计知识沉淀。核心是文档结构化、零件图像库建设、RAG检索和多模态识别。',
|
||||||
|
suggestedSolution: '建议建设“RAG知识库 + 零件图片识别模型 + 售后服务智能体”。先导入售后FAQ和技术文档,再扩展到拍照识别与工单推荐。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ012', company: '温州益坤电气股份有限公司', industry: '智能电气', region: '温州市·平阳县', size: '大型', maturityLevel: 'L3', maturityScore: 59, status: 'new',
|
||||||
|
painPoint: '企业希望打造覆盖档案、员工服务、技术标准、质量预警和不良分析的数字员工体系',
|
||||||
|
scenario: '企业数字员工智能体体系', department: '综合管理 / 技术部 / 质量部', dataReadiness: 61, budget: '200-500万', timeline: '9-18个月',
|
||||||
|
tags: ['数字员工', '知识管理', '质量预警', '智能体搭建'], urgency: 'medium', submittedAt: '2026-06-07',
|
||||||
|
businessDesc: '项目旨在通过知识管理、流程自动化、数据洞察与智能决策,搭建档案管理、员工自助问答、技术标准档案、质量数据预警、产品不良分析等智能体模块。',
|
||||||
|
technicalDesc: '该场景对应智能电气指引中的智能经营决策、AI质量检测和知识管理能力。重点是把企业知识资产统一管理、结构沉淀,并培养内部智能体搭建人才。',
|
||||||
|
suggestedSolution: '建议建立“企业智能体工作台”,以知识库和流程编排为底座,先落地员工问答和质量预警两个高频场景,再扩展多部门智能体。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ013', company: '超达阀门集团股份有限公司', industry: '工业泵阀', region: '温州市·永嘉县', size: '大型', maturityLevel: 'L3', maturityScore: 62, status: 'matching',
|
||||||
|
painPoint: '标书解析、资质匹配、业绩提取和标书检查耗时长且易出错',
|
||||||
|
scenario: '标书智能体', department: '销售部 / 商务部 / 法务部', dataReadiness: 65, budget: '150-350万', timeline: '3-6个月',
|
||||||
|
tags: ['标书解析', '资质证书', '业绩匹配', '合同条款'], urgency: 'high', submittedAt: '2026-06-06',
|
||||||
|
businessDesc: '企业需要AI智能体解析客户标书,归纳评分标准、资质证书、业绩和合同条款,自动给出初始评分、提取资质文件并打包,制作后进行系统性检查。',
|
||||||
|
technicalDesc: '该场景结合泵阀产品精准营销和企业知识库能力。需要建设标书文档解析、企业资质知识库、历史业绩数据库和风险校验规则。',
|
||||||
|
suggestedSolution: '建议建设“标书解析Agent + 企业资质/业绩RAG库 + 合规检查器”。先覆盖招标文件摘要、评分要点提取、资质匹配和易错项检查。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ014', company: '温州市金田塑业有限公司', industry: '新材料', region: '温州市·龙港市', size: '超大', maturityLevel: 'L3', maturityScore: 56, status: 'new',
|
||||||
|
painPoint: 'BOPP薄膜生产数据实时分析、设备故障预测和调度优化能力不足',
|
||||||
|
scenario: 'AI生产优化', department: '生产部 / 设备部 / 研发部', dataReadiness: 58, budget: '300-800万', timeline: '9-18个月',
|
||||||
|
tags: ['生产数据采集', '故障预测', '调度优化', '薄膜材料'], urgency: 'medium', submittedAt: '2026-06-06',
|
||||||
|
businessDesc: '企业希望实现生产数据实时采集、分析和预测,预测设备故障、自动优化调度,并探索未来模型在研发环节的应用。',
|
||||||
|
technicalDesc: '该需求对应新材料指引中的工艺动态优化、设备故障AI诊断与预测、质量智能分析。BOPP产线连续化程度高,适合做时序数据建模。',
|
||||||
|
suggestedSolution: '建议建设“薄膜产线数据湖 + 设备健康模型 + 工艺参数优化模型”。先围绕关键进口产线做数据采集和故障预警试点。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ015', company: '温州市康尔微晶器皿有限公司', industry: '新材料', region: '温州市·龙港市', size: '中型', maturityLevel: 'L2', maturityScore: 43, status: 'new',
|
||||||
|
painPoint: '微晶玻璃订单与库存联动不足,生产计划主要依赖人工安排',
|
||||||
|
scenario: '智排AI产销协同系统', department: '销售部 / 计划部 / 仓储部', dataReadiness: 45, budget: '80-200万', timeline: '6-9个月',
|
||||||
|
tags: ['产销协同', '库存优化', 'AI计划排产', '微晶玻璃'], urgency: 'medium', submittedAt: '2026-06-05',
|
||||||
|
businessDesc: '企业需要依据客户订单以及现有库存安排生产计划,降低缺货和积压,并提升生产组织响应速度。',
|
||||||
|
technicalDesc: '该需求适配新材料和智能装备参考指引中的AI计划排产、仓储智能管理和智能经营决策。关键是建立订单、库存、产能、交期四类数据联动。',
|
||||||
|
suggestedSolution: '建议先建设“订单-库存-产能”一体化看板,再上线智能排产和库存补货建议模型。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ016', company: '天信仪表集团有限公司', industry: '智能物联', region: '温州市·苍南县', size: '大型', maturityLevel: 'L3', maturityScore: 63, status: 'matched',
|
||||||
|
painPoint: '仪表装配工序中贴膜、密封圈装配等质量问题需要实时校验与原因分析',
|
||||||
|
scenario: 'AI视觉装配工序智能质检', department: '质量部 / 装配车间', dataReadiness: 68, budget: '200-500万', timeline: '6-12个月',
|
||||||
|
tags: ['装配质检', '机器视觉', '不良原因分析', '智能仪表'], urgency: 'high', submittedAt: '2026-06-05',
|
||||||
|
businessDesc: '企业希望结合机器视觉数据,为贴膜、密封圈装配提供实时校验指引,并分析历史良品率数据,定位不良原因,输出优化建议。',
|
||||||
|
technicalDesc: '对应智能物联指引中的AI智能检测、质量精准追溯和工艺动态优化。关键是把视觉判定结果与装配工序、人员、设备、批次数据关联。',
|
||||||
|
suggestedSolution: '建议建设“装配视觉校验 + 缺陷因果分析 + MES反馈闭环”。先从贴膜和密封圈两个工位做实时提醒,再扩展到全流程质检。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ017', company: '泰顺县维富玩具有限公司', industry: '智能装备', region: '温州市·泰顺县', size: '中型', maturityLevel: 'L2', maturityScore: 38, status: 'new',
|
||||||
|
painPoint: '木质玩具海外市场拓展、客户背景调查和质量缺陷模拟缺少AI工具支持',
|
||||||
|
scenario: '海外市场精准拓展与风险防控AI', department: '外贸部 / 研发部 / 质量部', dataReadiness: 36, budget: '50-150万', timeline: '6-12个月',
|
||||||
|
tags: ['海外市场分析', '客户风控', '产品质量模拟', '木质玩具'], urgency: 'medium', submittedAt: '2026-06-04',
|
||||||
|
businessDesc: '企业AI需求包括新产品质量控制与缺陷检测模拟、海外客户资质信用背景调查、国外木质玩具市场分析和前景分析模型。',
|
||||||
|
technicalDesc: '场景融合产品AI设计、产品虚拟验证和产品精准营销。数据来源包括产品结构、历史质检、外贸客户、海外市场报告和电商平台信息。',
|
||||||
|
suggestedSolution: '建议建设“外贸风控Agent + 海外市场洞察模型 + 产品缺陷模拟库”。先从客户背景调查和市场报告自动生成两个轻量场景切入。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ018', company: '浙江卢峰茶业有限公司', industry: '食品农产品', region: '温州市·泰顺县', size: '中型', maturityLevel: 'L2', maturityScore: 41, status: 'new',
|
||||||
|
painPoint: '茶叶种植、加工、库存、营销和客服全链路缺少智能管控',
|
||||||
|
scenario: '茶叶全产业链AI智能管控系统', department: '种植基地 / 生产部 / 电商部', dataReadiness: 40, budget: '100-300万', timeline: '9-18个月',
|
||||||
|
tags: ['茶园监测', '智能分拣', '加工调控', '精准营销'], urgency: 'medium', submittedAt: '2026-06-04',
|
||||||
|
businessDesc: '企业需要茶园智能监测预警、种植决策支持、鲜叶与干茶智能分拣分级、加工过程调控、库存预警、精准营销和AI客服。',
|
||||||
|
technicalDesc: '该需求虽属于农业加工,但与温州制造AI质量检测、工艺动态优化、智能经营决策场景相通。需要融合物联传感、视觉识别、销售预测和客服知识库。',
|
||||||
|
suggestedSolution: '建议采用“茶园IoT + 分拣视觉模型 + 加工参数优化 + 电商营销智能体”的分阶段建设路径。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ019', company: '浙江之翼通航有限公司', industry: '低空经济', region: '温州市·文成县', size: '中型', maturityLevel: 'L3', maturityScore: 54, status: 'new',
|
||||||
|
painPoint: '低空飞行运营涉及飞行器、空域、气象、客流和场馆机器人物业多源数据融合',
|
||||||
|
scenario: '低空安全运营AI数智管控系统', department: '运营中心 / 安全管理 / 文旅服务', dataReadiness: 55, budget: '300-800万', timeline: '9-18个月',
|
||||||
|
tags: ['低空安全', 'AI导览', '机器人协同', '飞行数据融合'], urgency: 'high', submittedAt: '2026-06-03',
|
||||||
|
businessDesc: '企业希望建设低空安全飞行数智服务中心,实时接入飞行器状态、空域环境、气象、客源、起降点、充电桩、交通接驳等数据,并探索AI导览、AI旅拍和机器人协同服务。',
|
||||||
|
technicalDesc: '对应低空航天器指引中的远程智能运维服务、产品虚拟验证和供应链风险预警。核心挑战是多源实时数据融合和安全运营闭环。',
|
||||||
|
suggestedSolution: '建议先建设“飞行运营数据中台 + 安全风险预警 + AI导览/旅拍轻应用”,再扩展到场馆机器人调度和低空运营智能中枢。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ020', company: '浙江德卡控制阀仪表有限公司', industry: '工业泵阀', region: '温州市·文成县', size: '中型', maturityLevel: 'L2', maturityScore: 47, status: 'new',
|
||||||
|
painPoint: '控制阀选型、报价和产品推荐依赖销售工程师经验,响应速度慢',
|
||||||
|
scenario: 'AI选型大模型', department: '销售部 / 技术支持 / ERP管理', dataReadiness: 49, budget: '100-300万', timeline: '6-12个月',
|
||||||
|
tags: ['AI选型', 'ERP集成', '智能报价', '产品推荐'], urgency: 'medium', submittedAt: '2026-06-03',
|
||||||
|
businessDesc: '企业希望在AI选型基础上集成ERP,自动产生价格给客户,并推荐符合要求的多种产品,拓宽销售市场。',
|
||||||
|
technicalDesc: '该需求对应泵阀产品AI设计和产品精准营销。需要构建控制阀参数、工况、材料、历史报价、库存与成本规则知识库。',
|
||||||
|
suggestedSolution: '建议建设“控制阀选型知识图谱 + 报价规则引擎 + 销售助手”。先覆盖高频工况选型,再接入ERP报价与客户管理。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ021', company: '正泰集团股份有限公司', industry: '智能电气', region: '温州市·乐清市', size: '超大', maturityLevel: 'L5', maturityScore: 88, status: 'pilot',
|
||||||
|
painPoint: '客户服务、销售、供应链、研发设计、生产优化和具身智能多场景需要集团级AI体系',
|
||||||
|
scenario: '工业全链路AI赋能与具身智能应用', department: '集团数字化 / 研发 / 供应链 / 客服', dataReadiness: 90, budget: '1000万以上', timeline: '12-24个月',
|
||||||
|
tags: ['集团大模型', '多模态设计', '供应链助手', '具身智能'], urgency: 'high', submittedAt: '2026-06-02',
|
||||||
|
businessDesc: '企业围绕客户服务、销售赋能、供应链管理、研发设计、生产优化、技术创新六大领域,构建全链路AI智能应用体系,并联合高校探索工业具身智能落地。',
|
||||||
|
technicalDesc: '正泰已有集团大模型和深度学习技术基础,适配智能电气指引中的产品AI设计、AI质量检测、智能经营决策、能源智能管控等多类场景。',
|
||||||
|
suggestedSolution: '建议采用“集团AI平台 + 场景智能体矩阵 + 数据治理与模型评测体系”。重点建设统一知识库、RAG能力、研发设计多模态模型和供应链/采购助手。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ022', company: '浙江人民电器公司', industry: '智能电气', region: '温州市·乐清市', size: '大型', maturityLevel: 'L3', maturityScore: 63, status: 'matching',
|
||||||
|
painPoint: '视觉质检、营销选型智能体和客服数字人需要统一平台化落地',
|
||||||
|
scenario: 'AI视觉质检+营销服务双智能体平台', department: '质量部 / 营销中心 / 客服中心', dataReadiness: 64, budget: '300-800万', timeline: '9-18个月',
|
||||||
|
tags: ['视觉质检', '营销选型', '客服数字人', '低压电器'], urgency: 'high', submittedAt: '2026-06-02',
|
||||||
|
businessDesc: '企业希望通过高清相机与AI视觉算法进行毫秒级外观缺陷检测,同时建设营销AI智能体和客服数字人,为一线人员提供产品知识和7x24小时咨询服务。',
|
||||||
|
technicalDesc: '该需求覆盖智能电气指引中的AI质量检测、产品精准营销和远程智能服务。需要打通知识库、质检数据和客服系统。',
|
||||||
|
suggestedSolution: '建议先建设“低压电器知识库 + 营销问答智能体”,并行开展关键工位视觉检测试点,最后将客服数字人与工单系统联动。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ023', company: '德力西电气有限公司', industry: '智能电气', region: '温州市·乐清市', size: '超大', maturityLevel: 'L4', maturityScore: 74, status: 'matched',
|
||||||
|
painPoint: '附件错漏装和制程质量波动需要AI视觉与MES形成预警闭环',
|
||||||
|
scenario: 'AI视觉赋能附件装配质检', department: '装配车间 / 质量部 / MES团队', dataReadiness: 78, budget: '300-800万', timeline: '6-12个月',
|
||||||
|
tags: ['附件错漏装', 'MES联动', '制程质量', '低压电气'], urgency: 'high', submittedAt: '2026-06-01',
|
||||||
|
businessDesc: '企业需要通过AI+视觉技术自动识别不同类型产品附件错漏装,并结合MES和AI视觉系统连通,形成预警闭环,确保制程质量稳定。',
|
||||||
|
technicalDesc: '该需求是智能电气AI质量检测的典型场景。关键在于产品型号多、附件组合多,需要视觉模型和BOM/MES工单做一致性校验。',
|
||||||
|
suggestedSolution: '建议建设“视觉检测工站 + BOM校验服务 + MES异常预警闭环”。先做高频产品附件识别,再扩展到装配行为合规检测。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ024', company: '合兴汽车电子股份有限公司', industry: '汽车零部件', region: '温州市·乐清市', size: '大型', maturityLevel: 'L4', maturityScore: 70, status: 'pilot',
|
||||||
|
painPoint: '供应商交付延迟和物料质量异常风险需要AI提前识别并推荐备选方案',
|
||||||
|
scenario: '供应链管控智能体', department: '供应链 / 采购 / 质量部', dataReadiness: 72, budget: '300-800万', timeline: '6-12个月',
|
||||||
|
tags: ['供应链预警', '供应商评估', '备选方案', '汽车电子'], urgency: 'high', submittedAt: '2026-06-01',
|
||||||
|
businessDesc: '企业需要实时联动供应商交付数据,由AI识别交付延迟、物料质量异常等风险,自动推送预警并匹配备选方案。',
|
||||||
|
technicalDesc: '该需求对应汽车零部件采购计划优化协同和供应链风险预警。需要汇聚供应商交付、质检、库存、订单和物流数据。',
|
||||||
|
suggestedSolution: '建议建设“供应商风险评分模型 + 交付预测模型 + 备选供应推荐Agent”。先覆盖关键物料和核心供应商。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ025', company: '红光电气集团有限公司', industry: '智能电气', region: '温州市·乐清市', size: '大型', maturityLevel: 'L3', maturityScore: 58, status: 'new',
|
||||||
|
painPoint: '变压器设计、报价、排产和供应链协同仍依赖规则与人工经验',
|
||||||
|
scenario: 'AI+变压器智能快速设计系统', department: '研发设计 / 报价 / 计划部', dataReadiness: 60, budget: '500-1000万', timeline: '12-24个月',
|
||||||
|
tags: ['变压器大模型', '自动出图', '智能报价', '柔性制造'], urgency: 'medium', submittedAt: '2026-05-31',
|
||||||
|
businessDesc: '企业拟基于机械设计本源规则开发变压器电气大模型引擎,研发智能快速设计系统,实现自动化出图、智能报价、成本核算、系统集成与数据共享。',
|
||||||
|
technicalDesc: '该需求对应智能电气产品AI设计和AI计划排产。需要沉淀变压器行业大数据知识库、大模型演变库和设计规则库。',
|
||||||
|
suggestedSolution: '建议先构建“变压器设计知识图谱 + 参数化设计引擎”,再逐步接入报价、计划排产、供应链和制造系统。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ026', company: '浙江红润光学有限公司', industry: '眼镜', region: '温州市·瓯海区', size: '中型', maturityLevel: 'L2', maturityScore: 45, status: 'new',
|
||||||
|
painPoint: '镜片外观缺陷和尺寸检测依赖人工,工艺参数优化缺少数据模型',
|
||||||
|
scenario: '镜片外观尺寸智能检测与工艺优化', department: '质量部 / 工艺部', dataReadiness: 50, budget: '100-300万', timeline: '6-12个月',
|
||||||
|
tags: ['镜片检测', '尺寸测量', '工艺优化', '眼镜产业'], urgency: 'medium', submittedAt: '2026-05-31',
|
||||||
|
businessDesc: '企业需要进行镜片外观缺陷识别与尺寸测量,并希望未来基于参数数据做工艺优化模型。',
|
||||||
|
technicalDesc: '该需求对应眼镜产业指引中的AI质量检测和质量智能分析与改进。需要采集镜片图像、尺寸测量结果、工艺参数和批次数据。',
|
||||||
|
suggestedSolution: '建议建设“镜片视觉检测 + 尺寸测量数据平台 + 工艺参数关联分析模型”。先从外观划痕、气泡、镀膜缺陷和尺寸偏差检测开始。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ027', company: '浙江一鸣食品股份有限公司', industry: '食品农产品', region: '温州市·平阳县', size: '超大', maturityLevel: 'L4', maturityScore: 73, status: 'matched',
|
||||||
|
painPoint: '连锁门店营销、排班、配补货和供应链库存需要AI驱动的全链路运营',
|
||||||
|
scenario: '零售全链路智能运营平台', department: '门店运营 / 供应链 / 会员营销', dataReadiness: 76, budget: '500-1000万', timeline: '9-18个月',
|
||||||
|
tags: ['会员营销', '门店排班', '预测配补货', '库存优化'], urgency: 'high', submittedAt: '2026-05-30',
|
||||||
|
businessDesc: '企业希望基于会员消费记录、行为数据和商品特性生成个性化营销方案;面向门店运营提供智能排班、商品组合优化、陈列建议和客流预测;并自动预测各门店需求。',
|
||||||
|
technicalDesc: '该场景对应智能经营决策、供应链库存优化和产品精准营销。数据基础包括门店POS、会员、天气、节假日、促销活动和库存物流数据。',
|
||||||
|
suggestedSolution: '建议建设“会员推荐模型 + 门店经营助手 + 预测补货引擎”。先从高频门店的需求预测和补货建议做试点。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'WZ028', company: '浙江三星机电股份有限公司', industry: '智能装备', region: '温州市·平阳县', size: '大型', maturityLevel: 'L4', maturityScore: 71, status: 'pilot',
|
||||||
|
painPoint: 'AI视觉检测需从单点扩展到全流程质检,并与MES/ERP形成数据闭环',
|
||||||
|
scenario: 'AI视觉质检全流程智能闭环平台', department: '质量部 / 生产部 / 信息化部', dataReadiness: 75, budget: '500-1000万', timeline: '9-18个月',
|
||||||
|
tags: ['多模态融合', '云边协同', '联邦学习', '数字孪生'], urgency: 'high', submittedAt: '2026-05-30',
|
||||||
|
businessDesc: '企业希望强化小样本学习与多模态融合能力,升级传感与算力设备,将AI视觉检测从单点延伸到全流程质检,并打通MES、ERP形成检测-分析-优化闭环。',
|
||||||
|
technicalDesc: '场景对应智能装备AI质量检测、质量智能分析与改进、设备故障AI诊断。云边协同和联邦学习可用于跨厂区模型迭代。',
|
||||||
|
suggestedSolution: '建议建设“云边协同视觉平台 + 缺陷数据闭环 + 跨厂区模型训练”。先标准化缺陷数据,再推进72小时模型迭代机制。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const maturityConfig: Record<MaturityLevel, { label: string; color: string; desc: string; bg: string }> = {
|
||||||
|
L1: { label: 'L1 初识', color: '#ff6b6b', bg: 'rgba(255,107,107,0.1)', desc: 'AI概念认知阶段,尚无数据积累' },
|
||||||
|
L2: { label: 'L2 探索', color: '#ffb340', bg: 'rgba(255,179,64,0.1)', desc: '有初步数字化基础,正在评估AI可行性' },
|
||||||
|
L3: { label: 'L3 试点', color: '#00d4ff', bg: 'rgba(0,212,255,0.1)', desc: '已有局部AI应用,寻求规模化落地' },
|
||||||
|
L4: { label: 'L4 扩展', color: '#7b61ff', bg: 'rgba(123,97,255,0.1)', desc: '多场景AI部署,构建AI应用体系' },
|
||||||
|
L5: { label: 'L5 领先', color: '#00e599', bg: 'rgba(0,229,153,0.1)', desc: '全面AI驱动,形成竞争差异化优势' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusConfig: Record<DemandStatus, { label: string; color: string }> = {
|
||||||
|
new: { label: '待对接', color: '#ffb340' },
|
||||||
|
matching: { label: '对接中', color: '#00d4ff' },
|
||||||
|
matched: { label: '已匹配', color: '#7b61ff' },
|
||||||
|
pilot: { label: '试点中', color: '#00e599' },
|
||||||
|
deployed: { label: '已落地', color: '#4fc3f7' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,672 @@
|
|||||||
|
// Design Doing 方法论知识库
|
||||||
|
// 由温州市"AI+制造"智创空间 · 鼎捷数智 提出
|
||||||
|
// 延续 Design Thinking(设计思维)的理念,聚焦制造业 AI 落地的实践行动
|
||||||
|
|
||||||
|
export interface DesignDoingStep {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
categoryIndex: number; // 1-5
|
||||||
|
designThinkingPhase: string; // 对应 Design Thinking 哪个阶段
|
||||||
|
coreQuestion: string; // 核心问题
|
||||||
|
whyItMatters: string; // 为什么重要
|
||||||
|
howToAssess: string; // 如何评估
|
||||||
|
commonPitfalls: string; // 常见误区
|
||||||
|
wenzhouContext: string; // 温州制造业特有背景
|
||||||
|
keywords: string[]; // 检索关键词
|
||||||
|
outputTemplate: string; // 输出模板
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesignDoingCategory {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
designThinkingMapping: string;
|
||||||
|
philosophy: string;
|
||||||
|
steps: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 方法论总览 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DESIGN_DOING_OVERVIEW = {
|
||||||
|
name: 'Design Doing 方法论',
|
||||||
|
subtitle: '制造业 AI 落地行动框架',
|
||||||
|
origin: '温州市"AI+制造"智创空间 · 鼎捷数智未来企业事业群',
|
||||||
|
version: 'v1.0(2026)',
|
||||||
|
designThinkingRelation: `Design Doing 是 Design Thinking(设计思维)的延续与落地化版本。
|
||||||
|
Design Thinking 强调"以人为中心"的创新思维——共情(Empathize)、定义(Define)、构思(Ideate)、原型(Prototype)、测试(Test)。
|
||||||
|
Design Doing 在此基础上,将"思维框架"转化为"行动框架":
|
||||||
|
从"我们应该做什么"(What to do)走向"我们怎么真正做到"(How to actually do it)。
|
||||||
|
尤其针对制造业 AI 落地场景,Design Doing 提供了更具操作性的10步评估与行动路径,
|
||||||
|
让"AI赋能制造"从概念走向可量化、可验收、可复制的实践。`,
|
||||||
|
corePhilosophy: `Design Doing 的核心理念:
|
||||||
|
1. 行动先于完美:不等待完美的数据和系统,在现有基础上找到切入点
|
||||||
|
2. 量化优于感觉:每一步都要有可量化的评估指标和验收KPI
|
||||||
|
3. 场景驱动技术:从真实业务痛点出发,而非从技术功能出发
|
||||||
|
4. 协作创造价值:制造企业 + AI翻译官 + 技术服务商 三位一体
|
||||||
|
5. 试点复制推广:小步快跑,用成功的试点经验驱动规模化落地`,
|
||||||
|
fiveCategories: ['自动化和数字化基础', '数据与知识积累', '团队与人才基础', 'AI应用现状', 'AI提升意愿'],
|
||||||
|
tenSteps: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 五大维度 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DESIGN_DOING_CATEGORIES: DesignDoingCategory[] = [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
name: '自动化和数字化基础',
|
||||||
|
designThinkingMapping: '对应 Design Thinking 的"共情(Empathize)"阶段',
|
||||||
|
philosophy: `在开展任何 AI 项目之前,必须深入理解企业当前的数字化现状——就像 Design Thinking 要求设计师深入共情用户一样。
|
||||||
|
AI 的本质是数据驱动,没有数字化基础,AI 就是无源之水。
|
||||||
|
这一维度要求我们诚实面对企业的真实起点:设备是否联网?数据是否可自动采集?核心业务系统是否已上线?
|
||||||
|
只有真正理解现状,才能制定可行的 AI 行动路径。`,
|
||||||
|
steps: [1, 3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
name: '数据与知识积累',
|
||||||
|
designThinkingMapping: '对应 Design Thinking 的"定义(Define)"阶段',
|
||||||
|
philosophy: `Design Thinking 的"定义"阶段要求明确真正的问题是什么。
|
||||||
|
在制造业 AI 落地中,真正的问题往往不是"技术不够先进",而是"数据不够干净、知识无法复用"。
|
||||||
|
这一维度聚焦两类核心资产:一是结构化的生产/经营数据(可以训练模型),二是工艺知识(可以构建知识库)。
|
||||||
|
数据与知识是 AI 能力的真正底层——没有它们,再好的算法也无处施展。`,
|
||||||
|
steps: [2, 5],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
name: '团队与人才基础',
|
||||||
|
designThinkingMapping: '对应 Design Thinking 的"构思(Ideate)"阶段',
|
||||||
|
philosophy: `Design Thinking 的"构思"阶段鼓励跨职能协作,汇聚不同视角产生创新方案。
|
||||||
|
在制造业 AI 落地中,团队构成决定了能否将好的想法转化为现实。
|
||||||
|
专职 IT/数字化团队是 AI 项目的执行保障;员工 AI 通识水平决定了接受程度和协作效率。
|
||||||
|
Design Doing 特别强调:AI 项目不能只靠外部服务商,企业内部团队必须具备"接得住、管得好"的能力。`,
|
||||||
|
steps: [4],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 4,
|
||||||
|
name: 'AI应用现状',
|
||||||
|
designThinkingMapping: '对应 Design Thinking 的"原型(Prototype)"阶段',
|
||||||
|
philosophy: `Design Thinking 的"原型"阶段强调快速构建可测试的解决方案。
|
||||||
|
在制造业 AI 落地中,"已有的 AI 应用现状"就是企业已经构建的"原型"——无论成功还是失败,都是宝贵的学习素材。
|
||||||
|
这一维度评估企业已引入哪些 AI 工具、已识别哪些场景、具备怎样的算力基础。
|
||||||
|
它既是现状的快照,也是下一步行动的起点:在已有基础上迭代优化,而非推倒重来。`,
|
||||||
|
steps: [6, 8, 10],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 5,
|
||||||
|
name: 'AI提升意愿',
|
||||||
|
designThinkingMapping: '对应 Design Thinking 的"测试(Test)"阶段',
|
||||||
|
philosophy: `Design Thinking 的"测试"阶段不仅是验证方案,更是验证用户真实意愿与接受度。
|
||||||
|
在制造业 AI 落地中,"提升意愿"决定了 AI 项目能否真正落地——管理层有没有决心投入、员工有没有意愿改变、企业文化是否支持试错。
|
||||||
|
Design Doing 认为:技术问题往往比组织问题更容易解决。
|
||||||
|
一把手重视、预算保障、顾虑疏导,这三者共同决定了 AI 项目是否能持续推进到规模化落地。`,
|
||||||
|
steps: [7, 9],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 十个步骤详解 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DESIGN_DOING_STEPS: DesignDoingStep[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
key: 'data_system',
|
||||||
|
title: '数据采集系统评估',
|
||||||
|
category: '自动化和数字化基础',
|
||||||
|
categoryIndex: 1,
|
||||||
|
designThinkingPhase: '共情(Empathize)— 了解企业真实的数字化现状',
|
||||||
|
coreQuestion: '企业是否已部署MES/SCADA/IoT等数据采集系统?设备数字化率和联网率如何?',
|
||||||
|
whyItMatters: `AI 的核心竞争力来自数据。没有自动化数据采集,AI 就只能依靠人工录入的低质量数据,
|
||||||
|
这是制造业 AI 项目失败的首要原因之一。
|
||||||
|
评估数据采集系统,是 Design Doing 第一步的核心——就像 Design Thinking 要求我们先"走进用户的世界"一样,
|
||||||
|
我们必须先走进工厂车间,真实了解数据是如何产生和流动的。
|
||||||
|
|
||||||
|
关键指标:
|
||||||
|
• 设备数字化率:多少台设备具备数据采集接口(PLC/传感器/网关)
|
||||||
|
• 设备联网率:多少台设备已接入局域网或工业互联网平台
|
||||||
|
• 数据采集方式:PLC自动采集 / 传感器实时采集 / 手工录入(质量最低)
|
||||||
|
• 数据采集频率:是否满足 AI 建模所需的时序密度`,
|
||||||
|
howToAssess: `评估框架(三档):
|
||||||
|
✅ 良好(≥70分):核心设备联网率>70%,有自动采集系统,数据可实时查看
|
||||||
|
⚠️ 待提升(40-70分):部分设备联网,数据采集存在断点,依赖人工辅助
|
||||||
|
❌ 需补基础(<40分):设备基本未联网,数据主要靠纸质记录或Excel
|
||||||
|
|
||||||
|
对于温州制造企业,不同产业分化明显:
|
||||||
|
• 智能电气(乐清):大型企业数字化率较高,中小型配套企业偏低
|
||||||
|
• 鞋业服装(瓯海/温州):生产线自动化程度差异大,品牌企业好于代工企业
|
||||||
|
• 工业泵阀(瓯北/永嘉):传统行业,半自动线居多,联网改造需求强烈`,
|
||||||
|
commonPitfalls: `常见误区:
|
||||||
|
1. "我们有ERP,数据问题不大" — ERP记录的是交易数据,不是生产过程数据
|
||||||
|
2. "先上AI,数据问题以后再说" — 没有数据,AI模型根本无法训练
|
||||||
|
3. "买一套采集系统就解决了" — 采集系统只是第一步,数据清洗和标注同样重要
|
||||||
|
4. 高估了"手工录入"的数据质量 — 手工数据误差通常在10%-30%,无法支撑高精度AI模型`,
|
||||||
|
wenzhouContext: `温州市特有背景:
|
||||||
|
温州市人工智能局已推出"设备上云"专项补贴,支持制造企业开展设备联网和数据采集体系改造。
|
||||||
|
建议企业先申请设备联网改造补贴,再在此基础上推进 AI 场景落地,可显著降低 AI 项目的数据基础投入。`,
|
||||||
|
keywords: ['数据采集', 'MES', 'SCADA', 'IoT', '设备联网', '数字化率', 'PLC', '传感器', '数据采集系统'],
|
||||||
|
outputTemplate: `步骤一评估结论:数据采集系统
|
||||||
|
当前状态:{status}
|
||||||
|
设备数字化率:{rate}%
|
||||||
|
主要采集方式:{method}
|
||||||
|
建议行动:{action}
|
||||||
|
与 Design Thinking 的连接:真正理解企业数字化现状,是后续所有 AI 行动的起点。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
key: 'data_quality',
|
||||||
|
title: '核心数据积累评估',
|
||||||
|
category: '数据与知识积累',
|
||||||
|
categoryIndex: 2,
|
||||||
|
designThinkingPhase: '定义(Define)— 明确数据资产的真实价值与缺口',
|
||||||
|
coreQuestion: '核心生产/经营数据积累情况如何?是否有结构化存储?数据质量(完整性/一致性)如何?',
|
||||||
|
whyItMatters: `数据是 AI 的"燃料"。但不是所有数据都可以直接"燃烧"——
|
||||||
|
就像 Design Thinking 的"定义"阶段要求我们从海量信息中提炼出真正有价值的洞察,
|
||||||
|
Data Doing 第二步要求我们评估:企业已有的数据,有多少可以真正用于 AI 训练?
|
||||||
|
|
||||||
|
AI 建模需要的数据质量要求:
|
||||||
|
• 完整性:关键字段的缺失率 < 5%
|
||||||
|
• 一致性:同一指标在不同系统中口径统一
|
||||||
|
• 时序性:生产过程数据要有精确的时间戳
|
||||||
|
• 标注性:质量检测结果等需要有明确的标签
|
||||||
|
|
||||||
|
核心生产数据类型:
|
||||||
|
• 设备运行数据(温度、压力、振动、电流等)
|
||||||
|
• 质量检测数据(合格/不合格、缺陷类型、检测时间)
|
||||||
|
• 工艺参数数据(加工参数、配方参数、工序参数)
|
||||||
|
• 订单与物料数据(需求预测、库存周转、供应链协同)`,
|
||||||
|
howToAssess: `评估框架:
|
||||||
|
✅ 良好:已建立数据一体化平台,历史数据积累>2年,数据完整性>90%
|
||||||
|
⚠️ 待提升:有部分结构化数据,但各系统之间存在数据孤岛
|
||||||
|
❌ 需提升:数据以纸质/Excel为主,无法直接用于AI建模
|
||||||
|
|
||||||
|
数据就绪度快速自评:
|
||||||
|
1. 能否找到过去1年的生产合格率历史数据?(质量AI基础)
|
||||||
|
2. 能否关联特定产品的工艺参数与质量结果?(工艺优化AI基础)
|
||||||
|
3. 历史订单数据是否有SKU维度的时序积累?(需求预测AI基础)`,
|
||||||
|
commonPitfalls: `常见误区:
|
||||||
|
1. "数据很多,肯定够用" — 量多不等于质高,数据清洗往往占 AI 项目60%的工作量
|
||||||
|
2. "ERP里的数据都有" — ERP数据颗粒度不够,无法支撑生产过程级别的AI分析
|
||||||
|
3. 忽视数据标注的重要性 — 尤其是视觉质检类场景,缺陷标注是核心壁垒
|
||||||
|
4. "以后补数据" — 历史数据缺失无法回溯,建议立即开始数据积累`,
|
||||||
|
wenzhouContext: `温州本地数据资源:
|
||||||
|
温州数据交易场所已推动部分行业级数据合规交易,制造企业可在数据合规框架下购买同行业脱敏数据集,
|
||||||
|
用于冷启动 AI 模型(尤其适合中小企业数据量不足的场景)。
|
||||||
|
鼎捷数智已为多家温州制造企业提供数据治理与数据资产化服务,可提供标准化数据清洗方案。`,
|
||||||
|
keywords: ['数据积累', '数据质量', '数据资产', '数据治理', '结构化数据', '历史数据', '数据标注', '数据清洗'],
|
||||||
|
outputTemplate: `步骤二评估结论:核心数据积累
|
||||||
|
数据就绪度:{readiness}%
|
||||||
|
主要数据类型:{types}
|
||||||
|
关键缺口:{gaps}
|
||||||
|
建议行动:{action}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
key: 'it_system',
|
||||||
|
title: '核心业务系统评估',
|
||||||
|
category: '自动化和数字化基础',
|
||||||
|
categoryIndex: 1,
|
||||||
|
designThinkingPhase: '共情(Empathize)— 理解企业信息化架构的完整图景',
|
||||||
|
coreQuestion: '是否已部署ERP/MES/WMS/PLM等核心业务系统?系统间是否实现数据互通?',
|
||||||
|
whyItMatters: `核心业务系统是企业数字化的"骨架"。AI 不是孤立运行的,它必须与 ERP、MES、WMS 等系统集成,
|
||||||
|
才能获取业务上下文数据,并将 AI 的输出结果落地到业务流程中。
|
||||||
|
没有系统集成的 AI,就像一个聪明的顾问,给出建议却没有执行权限。
|
||||||
|
|
||||||
|
ERP/MES/WMS 对 AI 的价值:
|
||||||
|
• ERP:提供订单、BOM、财务等经营数据,是需求预测和成本优化 AI 的数据来源
|
||||||
|
• MES:提供生产过程数据,是质量检测、设备维护、工艺优化 AI 的核心数据源
|
||||||
|
• WMS:提供仓储物流数据,是库存优化、物流调度 AI 的基础
|
||||||
|
• PLM/CAX:提供设计数据,是AI辅助设计、工艺文件生成的起点
|
||||||
|
|
||||||
|
系统集成是 AI 落地的关键瓶颈:
|
||||||
|
统计显示,超过60%的制造业 AI 项目因系统集成问题而延期或失败。`,
|
||||||
|
howToAssess: `评估框架:
|
||||||
|
✅ 良好:ERP+MES+WMS 均已部署,系统间实现数据互通,有统一数据平台
|
||||||
|
⚠️ 待提升:核心系统已部署,但数据孤岛问题严重,集成接口不完整
|
||||||
|
❌ 需补基础:核心系统缺失或仅部分部署,AI 集成难度极高
|
||||||
|
|
||||||
|
集成评估维度:
|
||||||
|
• ERP↔MES:生产订单是否自动下发?完工数据是否自动回传?
|
||||||
|
• MES↔设备:设备实时状态是否可见?工艺参数是否自动记录?
|
||||||
|
• WMS↔ERP:库存数据是否实时同步?`,
|
||||||
|
commonPitfalls: `常见误区:
|
||||||
|
1. "系统都上了,但还是用Excel" — 系统上线≠系统用好,应用深度更重要
|
||||||
|
2. 忽视系统版本问题 — 老版本 ERP/MES 可能缺少 API 接口,AI 集成成本极高
|
||||||
|
3. 不同系统不同供应商,集成是噩梦 — 建议在 AI 项目启动前先评估集成可行性
|
||||||
|
4. "系统有数据,AI肯定能用" — 系统数据的质量和结构仍需专项评估`,
|
||||||
|
wenzhouContext: `温州制造业系统部署现状:
|
||||||
|
根据温州市数字化水平评测,约45%的规模以上制造企业已部署ERP,
|
||||||
|
约28%部署了MES,仅约15%实现了 ERP+MES 的有效集成。
|
||||||
|
鼎捷数智为温州200+家制造企业提供 ERP/MES/WMS 系统服务,具备丰富的系统集成和 AI 嵌入经验。`,
|
||||||
|
keywords: ['ERP', 'MES', 'WMS', 'PLM', '核心业务系统', '系统集成', '数据互通', '信息化'],
|
||||||
|
outputTemplate: `步骤三评估结论:核心业务系统
|
||||||
|
已部署系统:{systems}
|
||||||
|
系统集成度:{integration}
|
||||||
|
数据互通状态:{dataflow}
|
||||||
|
AI集成可行性:{feasibility}
|
||||||
|
建议行动:{action}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
key: 'it_team',
|
||||||
|
title: 'IT与数字化团队评估',
|
||||||
|
category: '团队与人才基础',
|
||||||
|
categoryIndex: 3,
|
||||||
|
designThinkingPhase: '构思(Ideate)— 汇聚多元视角,构建 AI 落地的人才基础',
|
||||||
|
coreQuestion: '是否有专职IT/数字化团队?团队规模和AI基础通识水平如何?是否有外部数字化服务团队支持?',
|
||||||
|
whyItMatters: `Design Thinking 的"构思"阶段强调多元视角的碰撞——只有聚合不同背景的人,才能产生真正创新的方案。
|
||||||
|
在制造业 AI 落地中,团队构成直接决定了 AI 项目的成败。
|
||||||
|
|
||||||
|
一个理想的 AI 落地团队需要三类人:
|
||||||
|
• 业务专家:最了解痛点,懂得"哪里不对",但可能不懂 AI
|
||||||
|
• IT/数字化人员:了解系统架构,能做集成,但可能不懂业务深处
|
||||||
|
• AI/数据工程师(或外部服务商):懂算法和模型,但可能不懂制造业特殊性
|
||||||
|
|
||||||
|
三类人缺一不可,且必须有效协作。
|
||||||
|
AI翻译官的核心价值,正是在这三类人之间搭建"语言桥梁"。
|
||||||
|
|
||||||
|
团队评估维度:
|
||||||
|
• 规模:全职数字化人员数量
|
||||||
|
• 层级:是否有明确的数字化主管或首席数字官
|
||||||
|
• AI通识:团队对机器学习、大模型、工业视觉等基础概念的了解程度
|
||||||
|
• 外部协作:是否有稳定的外部数字化/AI服务团队`,
|
||||||
|
howToAssess: `评估框架:
|
||||||
|
✅ 良好:≥5人专职数字化团队,有明确负责人,AI通识水平良好,有稳定外部合作
|
||||||
|
⚠️ 待提升:1-3人兼职或专职,AI通识不足,主要依赖外部服务商
|
||||||
|
❌ 需补基础:无专职团队,完全依赖外部,技术接收能力弱
|
||||||
|
|
||||||
|
快速自评:
|
||||||
|
• 团队能否独立维护现有 ERP/MES 系统?(基础数字化能力)
|
||||||
|
• 团队是否了解"什么是机器学习""什么是大模型"?(AI通识)
|
||||||
|
• 团队能否基于 AI 工具进行企业内部场景的二次开发?(高阶AI能力)`,
|
||||||
|
commonPitfalls: `常见误区:
|
||||||
|
1. "有IT部门就够了" — IT部门运维系统,AI项目需要更高的数据/算法素养
|
||||||
|
2. "完全外包给服务商" — 外包项目结束后无人维护,AI项目难以持续迭代
|
||||||
|
3. 忽视员工AI培训 — 一线员工的使用意愿和技能是AI系统长期价值的关键
|
||||||
|
4. "先做项目,团队以后再建" — 团队能力建设周期长,应与 AI 项目并行推进`,
|
||||||
|
wenzhouContext: `温州市人才支持:
|
||||||
|
温州市人工智能局联合温州大学定期举办"AI+制造"专项培训,面向制造企业管理层和IT团队,
|
||||||
|
提供从AI基础认知到具体场景落地的系统化培训。
|
||||||
|
温州市AI翻译官体系(本平台AI翻译官)正是为了弥补企业内部 AI 能力不足而建立的外部专家支持机制。`,
|
||||||
|
keywords: ['IT团队', '数字化团队', 'AI人才', '培训', '数字化主管', '人才基础', '专职团队'],
|
||||||
|
outputTemplate: `步骤四评估结论:IT与数字化团队
|
||||||
|
团队规模:{size}人
|
||||||
|
AI通识水平:{aiLevel}
|
||||||
|
外部协作资源:{external}
|
||||||
|
建议行动:{action}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
key: 'knowledge',
|
||||||
|
title: '工艺知识沉淀评估',
|
||||||
|
category: '数据与知识积累',
|
||||||
|
categoryIndex: 2,
|
||||||
|
designThinkingPhase: '定义(Define)— 明确"隐性知识"是制造业 AI 的核心壁垒',
|
||||||
|
coreQuestion: '核心工艺知识是否有系统化沉淀?SOP/故障案例库/工艺参数优化经验,是纸质文档还是系统内置?是否建有企业知识库?',
|
||||||
|
whyItMatters: `制造业的核心竞争力,往往沉淀在老工人的脑子里和纸质文档中——这是最难被 AI 复用的知识形态。
|
||||||
|
工艺知识的系统化沉淀,是制造业 AI 区别于其他行业 AI 的最大特殊性。
|
||||||
|
|
||||||
|
工艺知识的三种形态(从难到易被AI利用):
|
||||||
|
• 纸质/口述经验:存在老员工离职风险,完全无法被 AI 利用
|
||||||
|
• 电子文档(Word/Excel):可被 RAG 知识库检索,但结构化程度低
|
||||||
|
• 系统内置/结构化数据库:可直接被 AI 建模,价值最高
|
||||||
|
|
||||||
|
工艺知识的核心类型:
|
||||||
|
• SOP(标准作业程序):生产流程的文字/视频化标准
|
||||||
|
• 故障案例库:设备故障现象、原因、处置方案的历史记录
|
||||||
|
• 工艺参数优化经验:不同产品/工装/批次下的最优参数组合
|
||||||
|
• 质量控制关键点:各工序的质量检验规程和缺陷分类标准
|
||||||
|
• 设备维护保养知识:维护手册、维修记录、预防性维护规程`,
|
||||||
|
howToAssess: `评估框架:
|
||||||
|
✅ 良好:核心 SOP 电子化,有系统化故障案例库,工艺参数有结构化记录,建有企业知识库
|
||||||
|
⚠️ 待提升:部分电子化,主要依赖有经验员工,知识传承存在风险
|
||||||
|
❌ 需提升:主要为纸质文档或口头经验,知识孤岛严重,传承断层风险高
|
||||||
|
|
||||||
|
RAG 知识库就绪度评估:
|
||||||
|
• 是否有可搜索的企业内部文档?
|
||||||
|
• 文档是否定期更新维护?
|
||||||
|
• 关键知识是否对新员工透明可查?`,
|
||||||
|
commonPitfalls: `常见误区:
|
||||||
|
1. "老工人都在,经验不会丢" — 一旦老工人退休或离职,数十年经验无处传承
|
||||||
|
2. "SOP都写了,问题不大" — SOP文档化≠知识可用,还需结构化和数字化
|
||||||
|
3. 知识库只建不用 — 知识库必须与 AI 问答系统打通,才能实现价值
|
||||||
|
4. "知识是商业机密" — 知识库的内部使用不会泄露商业机密,反而强化竞争壁垒`,
|
||||||
|
wenzhouContext: `温州制造业知识沉淀现状:
|
||||||
|
温州许多传统制造业(电气、泵阀、鞋业)的核心工艺知识仍以"师徒传授"为主,
|
||||||
|
数字化程度远低于新能源、汽车零部件等行业。
|
||||||
|
鼎捷数智已在多家温州电气企业开展工艺知识库建设项目,将纸质规程转化为 RAG 可检索知识图谱。`,
|
||||||
|
keywords: ['工艺知识', 'SOP', '故障案例库', '知识库', 'RAG', '工艺参数', '知识沉淀', '经验传承'],
|
||||||
|
outputTemplate: `步骤五评估结论:工艺知识沉淀
|
||||||
|
知识化形态:{form}
|
||||||
|
已结构化比例:{ratio}%
|
||||||
|
知识库建设状态:{kbStatus}
|
||||||
|
建议行动:{action}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
key: 'ai_exp',
|
||||||
|
title: 'AI应用经验评估',
|
||||||
|
category: 'AI应用现状',
|
||||||
|
categoryIndex: 4,
|
||||||
|
designThinkingPhase: '原型(Prototype)— 盘点已有的 AI "原型",从中学习',
|
||||||
|
coreQuestion: '是否已尝试过AI或机器学习项目?引入了哪些工具?应用场景、使用频率及效果如何?未引入的原因是什么?',
|
||||||
|
whyItMatters: `Design Thinking 的"原型"阶段强调:不完美的原型比完美的构想更有价值,因为它能告诉我们真实的反馈。
|
||||||
|
每个企业已有的 AI 应用经验(无论成功还是失败),都是宝贵的"原型测试"结果。
|
||||||
|
|
||||||
|
已有 AI 应用经验的评估维度:
|
||||||
|
• 引入了哪些工具?(工业视觉软件、智能问答、预测性维护系统、AI大模型等)
|
||||||
|
• 应用场景的价值密度如何?(高频低价值 vs 低频高价值)
|
||||||
|
• 实际使用效果与预期的差距?(过度期待 vs 实际价值)
|
||||||
|
• 未引入的真实原因?(不了解/成本过高/无适配场景/技术门槛)
|
||||||
|
|
||||||
|
常见的制造业 AI 工具类型:
|
||||||
|
• 工业视觉检测(表面缺陷识别、尺寸测量、装配验证)
|
||||||
|
• 智能问数/问答(大模型驱动的数据分析助手)
|
||||||
|
• 预测性维护(设备故障预测、寿命预测)
|
||||||
|
• AI排产(智能计划排程APS)
|
||||||
|
• 知识库与智能体(企业内部知识问答、流程自动化)`,
|
||||||
|
howToAssess: `评估框架:
|
||||||
|
✅ 良好:已有2个以上运行中的 AI 项目,效果可量化,团队使用习惯已建立
|
||||||
|
⚠️ 在探索:尝试过1-2个 AI 工具,但尚未形成稳定的使用习惯或量化价值
|
||||||
|
❌ 未启动:未引入任何 AI 工具(需了解真实原因,针对性解决)
|
||||||
|
|
||||||
|
未引入 AI 的原因分析:
|
||||||
|
• "不了解"→ 需要 AI 科普与场景演示
|
||||||
|
• "成本过高"→ 建议申领温州 AI 优惠券,降低试用门槛
|
||||||
|
• "无适配场景"→ 需要 AI 翻译官进行场景识别与价值评估
|
||||||
|
• "技术门槛高"→ 建议从低代码/零代码 AI 工具切入`,
|
||||||
|
commonPitfalls: `常见误区:
|
||||||
|
1. "试过,效果不好" — 效果不好通常因为数据质量差或场景选择错误,不代表 AI 没价值
|
||||||
|
2. "我们在观望" — 等待最佳时机往往意味着错过窗口期,竞争对手已在行动
|
||||||
|
3. 过度依赖单一工具 — AI 工具生态多样,不同场景需要匹配不同工具
|
||||||
|
4. 忽视 AI 工具的持续迭代 — AI 工具需要随业务数据增长持续优化,不是一次性项目`,
|
||||||
|
wenzhouContext: `温州 AI 工具应用现状:
|
||||||
|
根据温州市 2026 年第一批需求清单,温州制造企业对"工业视觉质检"和"AI计划排产"兴趣最强,
|
||||||
|
但实际落地率仍不足20%,主要障碍是数据基础薄弱和场景定义不清。
|
||||||
|
温州 AI 翻译官体系专门针对这一问题,帮助企业完成从"感兴趣"到"能落地"的跨越。`,
|
||||||
|
keywords: ['AI应用经验', 'AI工具', '工业视觉', '预测维护', '智能问答', 'AI项目', '使用效果'],
|
||||||
|
outputTemplate: `步骤六评估结论:AI应用经验
|
||||||
|
已引入工具:{tools}
|
||||||
|
运行中项目:{projects}
|
||||||
|
主要未引入原因:{reason}
|
||||||
|
建议行动:{action}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
key: 'leadership',
|
||||||
|
title: '管理层AI意愿评估',
|
||||||
|
category: 'AI提升意愿',
|
||||||
|
categoryIndex: 5,
|
||||||
|
designThinkingPhase: '测试(Test)— 验证组织对 AI 变革的真实接受度',
|
||||||
|
coreQuestion: '管理层是否有明确的AI转型目标和预算?一把手是否亲自参与?是否制定1-2年短期或3-5年中长期AI规划?',
|
||||||
|
whyItMatters: `在 AI 项目实践中,最常见的失败原因不是技术问题,而是"管理层不够重视"。
|
||||||
|
Design Thinking 的"测试"阶段要求我们验证方案能否真正被用户接受——
|
||||||
|
在 Design Doing 中,这意味着验证管理层是否真的愿意为 AI 转型投入资源和注意力。
|
||||||
|
|
||||||
|
管理层支持的三个层次:
|
||||||
|
• 表态支持(最低层次):口头认可 AI 的重要性,但没有实质行动
|
||||||
|
• 预算保障(中间层次):有明确的 AI 项目预算,但可能参与度不足
|
||||||
|
• 一把手亲自推动(最高层次):管理层直接参与场景定义、进度追踪和组织保障
|
||||||
|
|
||||||
|
为什么一把手重视如此关键:
|
||||||
|
• AI 项目往往需要跨部门协作,需要一把手协调资源
|
||||||
|
• AI 带来的流程变革可能遭遇员工抵触,需要管理层承担变革压力
|
||||||
|
• AI 预算优先级在多项目竞争中,需要一把手的决策保障
|
||||||
|
• AI 的效果周期通常6-18个月,需要管理层的耐心和信心`,
|
||||||
|
howToAssess: `评估框架:
|
||||||
|
✅ 良好:一把手亲自主导 AI 战略,有专项预算,制定了1-2年明确规划
|
||||||
|
⚠️ 待提升:中层积极推动,但一把手参与度不足,预算模糊
|
||||||
|
❌ 需提升:AI 仅作为IT部门的自发探索,缺乏管理层背书和预算支持
|
||||||
|
|
||||||
|
快速判断:
|
||||||
|
• 最近一次公司战略会议是否讨论过AI?(意识层面)
|
||||||
|
• 是否有专门用于 AI 的预算科目?(资源层面)
|
||||||
|
• 是否有人对 AI 项目的 ROI 负责?(责任层面)`,
|
||||||
|
commonPitfalls: `常见误区:
|
||||||
|
1. "管理层在看,等效果出来再说" — 等待期间,竞争对手已完成试点
|
||||||
|
2. "预算先小试" — 过小的预算导致项目质量不足,无法验证真实价值
|
||||||
|
3. 把 AI 项目定位为"IT升级" — AI 是业务变革项目,而非技术升级项目
|
||||||
|
4. 没有明确的 AI 项目责任人 — AI 项目失败很多是因为"人人负责等于没人负责"`,
|
||||||
|
wenzhouContext: `温州企业管理层 AI 认知现状:
|
||||||
|
根据调研,温州制造企业中约60%的老板"听说过 AI",但只有约25%有具体的 AI 落地规划。
|
||||||
|
温州市人工智能局定期举办"AI+制造"一把手培训班,帮助企业主从战略层面理解 AI 的竞争意义。
|
||||||
|
建议企业主参加温州市 AI 场景发布会,了解同行企业的 AI 落地案例,激活转型意愿。`,
|
||||||
|
keywords: ['管理层', '一把手', 'AI规划', 'AI预算', '战略', 'AI意愿', '领导力', 'AI转型'],
|
||||||
|
outputTemplate: `步骤七评估结论:管理层AI意愿
|
||||||
|
一把手参与度:{involvement}
|
||||||
|
AI预算状态:{budget}
|
||||||
|
规划完整性:{plan}
|
||||||
|
建议行动:{action}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
key: 'scenario',
|
||||||
|
title: 'AI场景识别评估',
|
||||||
|
category: 'AI应用现状',
|
||||||
|
categoryIndex: 4,
|
||||||
|
designThinkingPhase: '原型(Prototype)— 将创意转化为可验证的 AI 场景原型',
|
||||||
|
coreQuestion: '是否已识别出3个以上清晰的AI应用场景?这些场景是单企业需求还是行业共性需求?核心价值和可行性如何评估?',
|
||||||
|
whyItMatters: `场景识别是 AI 落地最关键的一步——选对场景,事半功倍;选错场景,前功尽弃。
|
||||||
|
Design Doing 第八步的核心价值在于:
|
||||||
|
• 将模糊的"想做 AI"转化为具体可执行的场景定义
|
||||||
|
• 区分"对我有价值"和"对行业有示范"的场景
|
||||||
|
• 评估场景的数据可行性、技术可行性和商业价值
|
||||||
|
|
||||||
|
高质量 AI 场景的四个特征:
|
||||||
|
1. 痛点真实且量化:有明确的业务损失数字,不是"感觉效率低"
|
||||||
|
2. 数据可支撑:有足够的历史数据或可以快速积累数据
|
||||||
|
3. 相比传统数字化有增量价值:用"原来数字化手段还不能解决"的问题来识别
|
||||||
|
4. 见效快、可推广:3-6个月能验证价值,且能在同行业复制
|
||||||
|
|
||||||
|
温州制造业高价值 AI 场景地图:
|
||||||
|
• 工业视觉质检(鞋业/电气/眼镜)— 高频、可量化、数据好积累
|
||||||
|
• AI计划排产(电气/汽配/装备)— 效益大、系统集成复杂
|
||||||
|
• 设备故障预测(新能源/泵阀/装备)— 价值高、需时序数据基础
|
||||||
|
• 工艺知识问答(电气/泵阀/汽配)— 易实施、员工接受度高
|
||||||
|
• 海外营销智能体(出口型企业)— 见效快、门槛低`,
|
||||||
|
howToAssess: `评估框架:
|
||||||
|
✅ 良好:已识别3+清晰场景,有痛点量化数据,区分了行业共性与企业个性需求
|
||||||
|
⚠️ 在梳理:有场景想法,但缺乏量化数据支撑,场景边界模糊
|
||||||
|
❌ 需帮助:只有"想做AI"的意愿,缺乏具体场景,需要 AI 翻译官介入
|
||||||
|
|
||||||
|
场景优先级评估矩阵(价值×可行性):
|
||||||
|
高价值+高可行性 → 优先启动
|
||||||
|
高价值+低可行性 → 分阶段准备
|
||||||
|
低价值+高可行性 → 作为能力建设积累
|
||||||
|
低价值+低可行性 → 不做`,
|
||||||
|
commonPitfalls: `常见误区:
|
||||||
|
1. "什么都想做" — 资源有限,必须聚焦优先级最高的1-2个场景
|
||||||
|
2. "跟着竞争对手做" — 别人的成功场景不一定适合你的数据基础和团队能力
|
||||||
|
3. 场景太大太复杂 — 应该拆分为小切口场景,快速验证
|
||||||
|
4. 忽视场景的数据可行性 — 最有业务价值的场景,不一定有最好的数据基础`,
|
||||||
|
wenzhouContext: `温州市 AI 场景识别支持:
|
||||||
|
温州市人工智能局每年发布《人工智能赋能新型工业化需求清单》,本平台展示的28家企业需求即来自该清单。
|
||||||
|
AI 翻译官的核心职责之一是帮助企业完成场景识别与优先级排序,将"模糊的 AI 期望"转化为"清晰的场景需求书"。`,
|
||||||
|
keywords: ['场景识别', 'AI场景', '场景优先级', '痛点量化', '场景可行性', '工业视觉', '知识问答', '智能排产'],
|
||||||
|
outputTemplate: `步骤八评估结论:AI场景识别
|
||||||
|
已识别场景数:{count}
|
||||||
|
优先推荐场景:{topScenario}
|
||||||
|
场景可行性评估:{feasibility}
|
||||||
|
建议行动:{action}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
key: 'concern',
|
||||||
|
title: 'AI顾虑与风险评估',
|
||||||
|
category: 'AI提升意愿',
|
||||||
|
categoryIndex: 5,
|
||||||
|
designThinkingPhase: '测试(Test)— 正视阻力与顾虑,是落地成功的必要前提',
|
||||||
|
coreQuestion: '推进AI应用的主要顾虑是什么?技术不成熟/数据安全/投入回报不确定/员工抵触?愿意投入的资源力度?',
|
||||||
|
whyItMatters: `Design Thinking 的"测试"阶段要求坦诚面对用户的质疑和抵触——这些质疑恰恰指出了方案的薄弱之处。
|
||||||
|
Design Doing 第九步的核心价值在于:不回避顾虑,而是系统化地识别和疏导顾虑,将阻力转化为行动计划。
|
||||||
|
|
||||||
|
制造业 AI 项目的五大常见顾虑:
|
||||||
|
1. 技术不成熟:"AI还不够准,不如人工可靠"
|
||||||
|
→ 解法:从高确定性场景切入(如视觉质检),建立信心
|
||||||
|
2. 数据安全:"数据上云怕泄露"
|
||||||
|
→ 解法:私有化部署方案,数据不出厂
|
||||||
|
3. 投入回报不确定:"花了钱不知道有没有效果"
|
||||||
|
→ 解法:签订KPI承诺,按效果付费
|
||||||
|
4. 员工抵触:"员工担心被AI替代"
|
||||||
|
→ 解法:将 AI 定位为"AI+人"协作,而非替代
|
||||||
|
5. 实施能力不足:"我们没有这个能力落地"
|
||||||
|
→ 解法:温州 AI 翻译官体系 + 优惠券支持,降低实施门槛
|
||||||
|
|
||||||
|
Design Doing 的核心主张:
|
||||||
|
顾虑≠障碍。每个顾虑背后,都有一个可以被解决的具体问题。
|
||||||
|
AI 翻译官的重要职责就是帮助企业将顾虑转化为行动计划。`,
|
||||||
|
howToAssess: `评估框架:
|
||||||
|
✅ 顾虑已疏导:主要顾虑已有具体解法,愿意投入明确资源推进
|
||||||
|
⚠️ 顾虑待疏导:顾虑较多,但有改变意愿,需要 AI 翻译官深入沟通
|
||||||
|
❌ 顾虑成壁垒:顾虑导致实质性停滞,需要先解决组织层面的问题
|
||||||
|
|
||||||
|
资源投入意愿评估:
|
||||||
|
• 资金:能否承诺阶段性 AI 专项预算(哪怕是小规模试点预算)
|
||||||
|
• 人力:能否从业务部门抽出1-2名核心人员全程参与
|
||||||
|
• 数据:是否愿意开放业务数据用于 AI 训练(在合规范围内)`,
|
||||||
|
commonPitfalls: `常见误区:
|
||||||
|
1. "等技术更成熟再说" — AI 技术每6个月迭代一次,永远都有"更好的技术在等待"
|
||||||
|
2. "先看别人怎么做" — 跟随者策略在 AI 时代成本极高,先行者已建立数据护城河
|
||||||
|
3. 把顾虑当成最终结论 — 顾虑是需要被管理的风险,而不是否定 AI 的理由
|
||||||
|
4. 不设止损点 — 可以从小规模试点开始,设置明确的评估节点,降低风险`,
|
||||||
|
wenzhouContext: `温州企业 AI 顾虑调研:
|
||||||
|
最常见的顾虑依次是:投入回报不确定(58%)、员工抵触(34%)、数据安全(28%)、技术不成熟(22%)。
|
||||||
|
温州市已推出"AI+制造先试先行"政策,允许企业以小规模试点方式验证 AI 价值,降低决策门槛。`,
|
||||||
|
keywords: ['AI顾虑', '数据安全', '员工抵触', '投入回报', '技术成熟度', '风险', '资源投入'],
|
||||||
|
outputTemplate: `步骤九评估结论:AI顾虑与风险
|
||||||
|
主要顾虑类型:{concerns}
|
||||||
|
顾虑疏导状态:{status}
|
||||||
|
资源投入意愿:{resource}
|
||||||
|
建议行动:{action}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
key: 'infra',
|
||||||
|
title: 'AI算力与基础设施评估',
|
||||||
|
category: 'AI应用现状',
|
||||||
|
categoryIndex: 4,
|
||||||
|
designThinkingPhase: '原型(Prototype)— 确保有足够的"工具"来构建和运行 AI 原型',
|
||||||
|
coreQuestion: '是否已有明确的AI算力和部署规划?自建/租赁/云边端混合?是否有国产化适配要求?预算规划如何?',
|
||||||
|
whyItMatters: `AI 模型需要算力支持——这是不可绕过的基础设施问题。
|
||||||
|
Design Doing 第十步关注的不仅是"现在有多少算力",更是"未来 AI 规模化时需要什么算力"。
|
||||||
|
|
||||||
|
制造业 AI 算力需求的特殊性:
|
||||||
|
• 实时推理需求:工业视觉质检要求毫秒级响应,必须本地边缘计算
|
||||||
|
• 数据安全需求:工艺配方、质量数据不能上公有云,需要私有化部署
|
||||||
|
• 国产化要求:部分军工、特定行业需要国产GPU和国产操作系统
|
||||||
|
• 弹性训练需求:模型初次训练数据量大,适合云端弹性算力
|
||||||
|
|
||||||
|
三种主流部署模式:
|
||||||
|
1. 本地私有化部署(On-premise)
|
||||||
|
适合:数据高度敏感、实时推理要求高的场景
|
||||||
|
设备:GPU服务器(如RTX5090/国产GPU)+ 边缘计算节点
|
||||||
|
2. 云端弹性算力(Cloud)
|
||||||
|
适合:模型训练阶段、数据安全要求较低的场景
|
||||||
|
平台:阿里云/腾讯云/华为云工业AI平台
|
||||||
|
3. 云边端混合(Hybrid)
|
||||||
|
适合:大多数制造业场景——云端训练+边缘推理
|
||||||
|
优势:兼顾成本与实时性,是当前最主流的架构`,
|
||||||
|
howToAssess: `评估框架:
|
||||||
|
✅ 良好:有明确的算力规划,已配置或规划配置边缘推理设备,部署模式明确
|
||||||
|
⚠️ 待规划:算力需求模糊,尚未形成具体规划
|
||||||
|
❌ 需支持:完全没有算力基础,需要从算力租赁方案开始
|
||||||
|
|
||||||
|
算力规划快速评估:
|
||||||
|
• 推理侧:是否有边缘计算设备?(工厂现场实时推理必需)
|
||||||
|
• 训练侧:是否可接入云端GPU算力?(模型训练阶段)
|
||||||
|
• 国产化:是否有国产化要求?(影响硬件选型)`,
|
||||||
|
commonPitfalls: `常见误区:
|
||||||
|
1. "买了GPU就能做AI" — 算力只是基础,算法、数据、工程能力同样重要
|
||||||
|
2. "全部上云" — 工业数据上云有数据安全风险,且实时推理延迟不可接受
|
||||||
|
3. "等设备到位再规划AI项目" — 算力规划应与 AI 场景设计同步进行
|
||||||
|
4. 忽视算力运维成本 — GPU设备需要专业维护,总拥有成本要考虑运维费用`,
|
||||||
|
wenzhouContext: `温州算力支持:
|
||||||
|
温州市人工智能局算力券可支持制造企业租用云端 GPU 算力,用于 AI 模型训练阶段,
|
||||||
|
显著降低中小企业的算力投入门槛。
|
||||||
|
温州工业互联网公共服务平台提供"算力即服务",按需调用,适合中小制造企业。`,
|
||||||
|
keywords: ['算力', 'GPU', '边缘计算', '云边端', '私有化部署', '国产化', '算力规划', '基础设施'],
|
||||||
|
outputTemplate: `步骤十评估结论:AI算力与基础设施
|
||||||
|
当前算力状态:{status}
|
||||||
|
推荐部署模式:{mode}
|
||||||
|
国产化要求:{domestic}
|
||||||
|
建议行动:{action}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 知识库检索函数 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 根据用户提问关键词,检索最相关的 Design Doing 知识条目 */
|
||||||
|
export function searchDesignDoing(query: string): {
|
||||||
|
matchedSteps: DesignDoingStep[];
|
||||||
|
matchedCategories: DesignDoingCategory[];
|
||||||
|
isOverviewQuery: boolean;
|
||||||
|
isMethodologyQuery: boolean;
|
||||||
|
} {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
|
||||||
|
const isOverviewQuery = ['设计思维', 'design thinking', 'design doing', '方法论', '十步', '10步', '五大维度', '延续', '框架', '理念'].some(k => q.includes(k));
|
||||||
|
const isMethodologyQuery = isOverviewQuery || q.includes('方法') || q.includes('步骤') || q.includes('维度');
|
||||||
|
|
||||||
|
const matchedSteps = DESIGN_DOING_STEPS.filter(step =>
|
||||||
|
step.keywords.some(kw => q.includes(kw.toLowerCase())) ||
|
||||||
|
q.includes(step.title.toLowerCase()) ||
|
||||||
|
q.includes(step.key.toLowerCase()) ||
|
||||||
|
q.includes(step.category.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const matchedCategories = DESIGN_DOING_CATEGORIES.filter(cat =>
|
||||||
|
q.includes(cat.name.toLowerCase()) ||
|
||||||
|
cat.steps.some(sId => matchedSteps.some(s => s.id === sId))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { matchedSteps, matchedCategories, isOverviewQuery, isMethodologyQuery };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成 Design Doing 方法论的智能问答回复 */
|
||||||
|
export function generateDesignDoingReply(query: string, demandContext?: {
|
||||||
|
company: string; industry: string; maturityLevel: string; maturityScore: number; dataReadiness: number;
|
||||||
|
}): { thinking: string; content: string } {
|
||||||
|
const { matchedSteps, matchedCategories, isOverviewQuery, isMethodologyQuery } = searchDesignDoing(query);
|
||||||
|
|
||||||
|
// 方法论总览查询
|
||||||
|
if (isOverviewQuery || (matchedSteps.length === 0 && isMethodologyQuery)) {
|
||||||
|
return {
|
||||||
|
thinking: `检索 Design Doing 方法论知识库…\n识别查询意图:方法论总览…\n调取 Design Doing 与 Design Thinking 关系说明…\n生成结构化回复…`,
|
||||||
|
content: `Design Doing 方法论 · 总览\n\n${DESIGN_DOING_OVERVIEW.designThinkingRelation}\n\n${DESIGN_DOING_OVERVIEW.corePhilosophy}\n\n五大维度与十个步骤:\n\n` +
|
||||||
|
DESIGN_DOING_CATEGORIES.map(cat =>
|
||||||
|
`${cat.index}. ${cat.name}(${cat.designThinkingMapping})\n 步骤:${cat.steps.map(id => `Step ${id}`).join('、')}\n 核心理念:${cat.philosophy.split('\n')[0]}`
|
||||||
|
).join('\n\n') +
|
||||||
|
`\n\n发布单位:${DESIGN_DOING_OVERVIEW.origin}\n版本:${DESIGN_DOING_OVERVIEW.version}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单步骤详细查询
|
||||||
|
if (matchedSteps.length === 1) {
|
||||||
|
const step = matchedSteps[0];
|
||||||
|
const cat = DESIGN_DOING_CATEGORIES.find(c => c.name === step.category)!;
|
||||||
|
return {
|
||||||
|
thinking: `检索 Design Doing 步骤知识库…\n定位到步骤${step.id}:${step.title}…\n调取详细说明与温州制造业背景…\n${demandContext ? `结合当前企业 ${demandContext.company} 上下文生成建议…` : ''}`,
|
||||||
|
content: `Design Doing · 步骤${step.id}:${step.title}\n所属维度:${step.category}(${cat.designThinkingMapping})\n\n核心问题:\n${step.coreQuestion}\n\n为什么重要:\n${step.whyItMatters}\n\n如何评估:\n${step.howToAssess}\n\n${step.commonPitfalls}\n\n${step.wenzhouContext}` +
|
||||||
|
(demandContext ? `\n\n针对 ${demandContext.company}(${demandContext.industry},成熟度 ${demandContext.maturityLevel})的具体建议:\n• 数据就绪度 ${demandContext.dataReadiness}%,${demandContext.dataReadiness >= 70 ? `步骤${step.id}基础较好,可直接推进` : `需先补强步骤${step.id}的基础条件`}` : ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多步骤或维度查询
|
||||||
|
if (matchedSteps.length > 1 || matchedCategories.length > 0) {
|
||||||
|
const targetCats = matchedCategories.length > 0 ? matchedCategories : DESIGN_DOING_CATEGORIES.filter(c => matchedSteps.some(s => s.categoryIndex === c.index));
|
||||||
|
return {
|
||||||
|
thinking: `检索 Design Doing 知识库…\n识别相关维度:${targetCats.map(c => c.name).join('、')}…\n整合多步骤知识…`,
|
||||||
|
content: `Design Doing 方法论 · ${targetCats.map(c => c.name).join(' × ')}\n\n` +
|
||||||
|
targetCats.map(cat =>
|
||||||
|
`【${cat.name}】\n${cat.philosophy.split('\n')[0]}\n\n` +
|
||||||
|
DESIGN_DOING_STEPS.filter(s => cat.steps.includes(s.id)).map(step =>
|
||||||
|
`Step ${step.id}·${step.title}:\n${step.whyItMatters.split('\n')[0]}\n→ 评估要点:${step.coreQuestion}`
|
||||||
|
).join('\n\n')
|
||||||
|
).join('\n\n─────\n\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用 Design Doing 回复
|
||||||
|
return {
|
||||||
|
thinking: `检索 Design Doing 知识库…\n未找到精确匹配,返回方法论入门说明…`,
|
||||||
|
content: `Design Doing 方法论 · 快速入门\n\nDesign Doing 是由温州市"AI+制造"智创空间提出的制造业 AI 落地行动框架,延续 Design Thinking(设计思维)的理念,将"思维"转化为"行动"。\n\n您可以提问的方向:\n• "Design Doing 和 Design Thinking 有什么关系?"\n• "步骤1/数据采集 是什么意思?"\n• "自动化和数字化基础维度包含哪些内容?"\n• "工艺知识沉淀怎么评估?"\n• "什么是好的 AI 场景?"\n• "管理层不重视怎么办?"\n• "算力规划应该怎么做?"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
// 雷勃动力传动(漳州)有限公司 — 轴承订单盈利能力分析数据模型
|
||||||
|
|
||||||
|
export type OrderStatus = 'open' | 'closed' | 'production' | 'shipped';
|
||||||
|
export type Channel = '外销-B2B' | '外销-B2C' | '内销-直销' | '内销-代理' | '电商平台';
|
||||||
|
export type RiskLevel = 'normal' | 'warning' | 'danger';
|
||||||
|
export type DeliveryPriority = 'urgent' | 'high' | 'normal' | 'low';
|
||||||
|
|
||||||
|
/** 客户名称脱敏:前两个字符替换为 **,保留剩余字符 */
|
||||||
|
export function maskCustomerName(name: string): string {
|
||||||
|
if (!name || name.length <= 2) return '**';
|
||||||
|
return '**' + name.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostDetail {
|
||||||
|
// 直接材料
|
||||||
|
fabric: number; // 钢材
|
||||||
|
accessories: number; // 配件(保持架/密封件)
|
||||||
|
// 直接人工
|
||||||
|
labor: number;
|
||||||
|
// 其他费用(制造费用)
|
||||||
|
overhead: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostRow {
|
||||||
|
type: '原材料成本' | '直接人工成本' | '管理成本';
|
||||||
|
qty: number;
|
||||||
|
unitPrice: number;
|
||||||
|
revenue: number; // 销售收入(含税)
|
||||||
|
directMaterial: number; // 直接材料
|
||||||
|
directLabor: number; // 直接人工
|
||||||
|
manufacturingCost: number; // 制造费用
|
||||||
|
totalCost: number; // 制造成本合计
|
||||||
|
grossProfit: number; // 毛利
|
||||||
|
grossMargin: number; // 毛利率 (小数)
|
||||||
|
mgmtExpense: number; // 管理销研费用
|
||||||
|
netProfit: number; // 净利润
|
||||||
|
netMargin: number; // 净利率 (小数)
|
||||||
|
detail: CostDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteCostItem {
|
||||||
|
name: string;
|
||||||
|
quotedPrice: number; // 报价成本单价
|
||||||
|
quantity: number;
|
||||||
|
totalCost: number;
|
||||||
|
percentage: number; // 占报价比例
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostSubItem {
|
||||||
|
label: string;
|
||||||
|
stdCost: number;
|
||||||
|
stdPct: number;
|
||||||
|
purchaseCost: number;
|
||||||
|
purchasePct: number;
|
||||||
|
usageCost: number;
|
||||||
|
usagePct: number;
|
||||||
|
purchaseVsStdAmt: number;
|
||||||
|
purchaseVsStdPct: number;
|
||||||
|
usageVsStdAmt: number;
|
||||||
|
usageVsStdPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostBreakdown {
|
||||||
|
category: string;
|
||||||
|
items: CostSubItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionStep {
|
||||||
|
name: string;
|
||||||
|
cost: number; // 工序成本(元)
|
||||||
|
duration: number; // 耗时(小时)
|
||||||
|
defectRate: number; // 不良率(小数)
|
||||||
|
hasIssue: boolean; // 是否存在工艺问题
|
||||||
|
issueDesc?: string; // 问题描述
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionLine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
equipment: string; // 自动线/半自动线/混合设备
|
||||||
|
totalCost: number;
|
||||||
|
totalHours: number;
|
||||||
|
yieldRate: number; // 综合良率(小数)
|
||||||
|
steps: ProductionStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id: string;
|
||||||
|
orderNo: string; // 制造令号
|
||||||
|
customer: string; // 客户
|
||||||
|
salesperson: string; // 业务员
|
||||||
|
channel: Channel;
|
||||||
|
closedDate: string; // 结单日期
|
||||||
|
productCategory: string; // 品类
|
||||||
|
status: OrderStatus;
|
||||||
|
riskLevel: RiskLevel;
|
||||||
|
deliveryPriority: DeliveryPriority;
|
||||||
|
region: string;
|
||||||
|
rows: CostRow[]; // 原材料成本/直接人工成本/管理成本三行
|
||||||
|
costBreakdown: CostBreakdown[]; // 成本明细附表
|
||||||
|
quoteCostItems?: QuoteCostItem[]; // 与客户报价成本细项对比
|
||||||
|
productionLines?: ProductionLine[]; // 生产线成本对比
|
||||||
|
tags: string[];
|
||||||
|
submittedAt: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 默认生产线数据 ────────────────────────────────────────────────────────────
|
||||||
|
const DEFAULT_PRODUCTION_LINES: ProductionLine[] = [
|
||||||
|
{ id: 'A', name: 'A线', equipment: '自动线', totalCost: 108500, totalHours: 52.5, yieldRate: 0.970,
|
||||||
|
steps: [
|
||||||
|
{ name: '下料', cost: 35000, duration: 8, defectRate: 0.003, hasIssue: false },
|
||||||
|
{ name: '车加工', cost: 22000, duration: 10, defectRate: 0.005, hasIssue: false },
|
||||||
|
{ name: '热处理', cost: 15000, duration: 6, defectRate: 0.012, hasIssue: true, issueDesc: '温控偶有偏差±2℃' },
|
||||||
|
{ name: '磨加工', cost: 18000, duration: 12, defectRate: 0.006, hasIssue: false },
|
||||||
|
{ name: '装配', cost: 12000, duration: 10, defectRate: 0.004, hasIssue: false },
|
||||||
|
{ name: '检验', cost: 6500, duration: 6.5, defectRate: 0, hasIssue: false },
|
||||||
|
]},
|
||||||
|
{ id: 'B', name: 'B线', equipment: '半自动线', totalCost: 136000, totalHours: 72, yieldRate: 0.872,
|
||||||
|
steps: [
|
||||||
|
{ name: '下料', cost: 38000, duration: 12, defectRate: 0.008, hasIssue: false },
|
||||||
|
{ name: '车加工', cost: 28000, duration: 14, defectRate: 0.015, hasIssue: true, issueDesc: '主轴跳动超标,刀具磨损快' },
|
||||||
|
{ name: '热处理', cost: 22000, duration: 8, defectRate: 0.042, hasIssue: true, issueDesc: '温控老化,硬度不均匀' },
|
||||||
|
{ name: '磨加工', cost: 24000, duration: 16, defectRate: 0.038, hasIssue: true, issueDesc: '砂轮磨损快,精度不稳定' },
|
||||||
|
{ name: '装配', cost: 15000, duration: 14, defectRate: 0.032, hasIssue: true, issueDesc: '工装松动,配合间隙偏大' },
|
||||||
|
{ name: '检验', cost: 9000, duration: 8, defectRate: 0, hasIssue: false },
|
||||||
|
]},
|
||||||
|
{ id: 'C', name: 'C线', equipment: '混合设备', totalCost: 118200, totalHours: 62, yieldRate: 0.934,
|
||||||
|
steps: [
|
||||||
|
{ name: '下料', cost: 36000, duration: 10, defectRate: 0.005, hasIssue: false },
|
||||||
|
{ name: '车加工', cost: 24000, duration: 12, defectRate: 0.008, hasIssue: false },
|
||||||
|
{ name: '热处理', cost: 18000, duration: 7, defectRate: 0.018, hasIssue: false },
|
||||||
|
{ name: '磨加工', cost: 20000, duration: 14, defectRate: 0.025, hasIssue: true, issueDesc: '老磨床精度偏低' },
|
||||||
|
{ name: '装配', cost: 13000, duration: 12, defectRate: 0.012, hasIssue: false },
|
||||||
|
{ name: '检验', cost: 7200, duration: 7, defectRate: 0, hasIssue: false },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 订单生成器(轴承产品) ──────────────────────────────────────────────────────
|
||||||
|
function genOrder(p: {
|
||||||
|
id: string; orderNo: string; customer: string; salesperson: string;
|
||||||
|
channel: Channel; closedDate: string; productCategory: string;
|
||||||
|
status: OrderStatus; riskLevel: RiskLevel; deliveryPriority?: DeliveryPriority; region: string;
|
||||||
|
tags: string[]; submittedAt: string; note?: string;
|
||||||
|
qty: number; unitPrice: number;
|
||||||
|
stdMat: number; stdLabor: number; stdOH: number;
|
||||||
|
purMatAdj: number; useMatAdj: number; useLaborAdj: number;
|
||||||
|
mgmt: number; matSplit?: [number, number];
|
||||||
|
}): Order {
|
||||||
|
const revenue = Math.round(p.qty * p.unitPrice);
|
||||||
|
const sp = p.matSplit || [0.7, 0.3];
|
||||||
|
const mkRow = (type: '原材料成本'|'直接人工成本'|'管理成本', matA: number, labA: number): CostRow => {
|
||||||
|
const dm = Math.round(p.stdMat * (1 + matA));
|
||||||
|
const dl = Math.round(p.stdLabor * (1 + labA));
|
||||||
|
const mc = p.stdOH; const tc = dm + dl + mc;
|
||||||
|
const gp = revenue - tc; const gm = revenue > 0 ? gp / revenue : 0;
|
||||||
|
const np = gp - p.mgmt; const nm = revenue > 0 ? np / revenue : 0;
|
||||||
|
return { type, qty: p.qty, unitPrice: p.unitPrice, revenue, directMaterial: dm, directLabor: dl,
|
||||||
|
manufacturingCost: mc, totalCost: tc, grossProfit: gp, grossMargin: +gm.toFixed(4),
|
||||||
|
mgmtExpense: p.mgmt, netProfit: np, netMargin: +nm.toFixed(4),
|
||||||
|
detail: { fabric: Math.round(dm * sp[0]), accessories: Math.round(dm * sp[1]), labor: dl, overhead: mc } };
|
||||||
|
};
|
||||||
|
const std = mkRow('原材料成本', 0, 0);
|
||||||
|
const pur = mkRow('直接人工成本', p.purMatAdj, 0);
|
||||||
|
const use = mkRow('管理成本', p.useMatAdj, p.useLaborAdj);
|
||||||
|
const bd = (_r: CostRow): CostBreakdown[] => makeBearingBreakdown(std, pur, use, revenue, p.stdOH);
|
||||||
|
return { ...p, deliveryPriority: p.deliveryPriority || 'normal', rows: [std, pur, use], costBreakdown: bd(std), productionLines: DEFAULT_PRODUCTION_LINES };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 轴承组件成本明细生成器 ──────────────────────────────────────────────────
|
||||||
|
const BEARING_COMP = [
|
||||||
|
{ label: '外圈', pct: 0.2076 }, { label: '内圈', pct: 0.1648 },
|
||||||
|
{ label: '滚针', pct: 0.4712 }, { label: '保持器', pct: 0.0617 },
|
||||||
|
{ label: '挡圈', pct: 0.0461 }, { label: '密封', pct: 0.0383 },
|
||||||
|
{ label: '油塞', pct: 0.0103 },
|
||||||
|
];
|
||||||
|
function makeBearingBreakdown(std: CostRow, pur: CostRow, use: CostRow, revenue: number, stdOH: number): CostBreakdown[] {
|
||||||
|
const mkItem = (label: string, pct: number) => {
|
||||||
|
const s = Math.round(std.directMaterial * pct);
|
||||||
|
const p = Math.round(pur.directMaterial * pct);
|
||||||
|
const u = Math.round(use.directMaterial * pct);
|
||||||
|
return { label, stdCost: s, stdPct: s / revenue, purchaseCost: p, purchasePct: p / revenue,
|
||||||
|
usageCost: u, usagePct: u / revenue,
|
||||||
|
purchaseVsStdAmt: p - s, purchaseVsStdPct: s ? (p - s) / s : 0,
|
||||||
|
usageVsStdAmt: u - s, usageVsStdPct: s ? (u - s) / s : 0 };
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
{ category: '原材料', items: BEARING_COMP.map(c => mkItem(c.label, c.pct)) },
|
||||||
|
{ category: '直接人工', items: [
|
||||||
|
{ label: '人工', stdCost: std.directLabor, stdPct: std.directLabor / revenue,
|
||||||
|
purchaseCost: pur.directLabor, purchasePct: pur.directLabor / revenue,
|
||||||
|
usageCost: use.directLabor, usagePct: use.directLabor / revenue,
|
||||||
|
purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: use.directLabor - std.directLabor, usageVsStdPct: std.directLabor ? (use.directLabor - std.directLabor) / std.directLabor : 0 },
|
||||||
|
]},
|
||||||
|
{ category: '其他费用', items: [
|
||||||
|
{ label: '费用', stdCost: stdOH, stdPct: 0, purchaseCost: stdOH, purchasePct: 0,
|
||||||
|
usageCost: stdOH, usagePct: 0, purchaseVsStdAmt: 0, purchaseVsStdPct: 0, usageVsStdAmt: 0, usageVsStdPct: 0 },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 订单数据 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ORDERS: Order[] = [
|
||||||
|
// ── ORD001: 三一重工 — 深沟球轴承 6205-2RS ──────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'ORD001', orderNo: 'RB-ZZ-260315001', customer: '三一重工', salesperson: '林志强',
|
||||||
|
channel: '内销-直销', closedDate: '2026-04-20', productCategory: '深沟球轴承',
|
||||||
|
status: 'closed', riskLevel: 'normal', deliveryPriority: 'normal', region: '华东区',
|
||||||
|
tags: ['标准品', '战略客户', '成本稳定'], submittedAt: '2026-06-10',
|
||||||
|
rows: [
|
||||||
|
{ type: '原材料成本', qty: 5000, unitPrice: 85.67, revenue: 428350,
|
||||||
|
directMaterial: 243000, directLabor: 72000, manufacturingCost: 54000, totalCost: 369000,
|
||||||
|
grossProfit: 59350, grossMargin: 0.1386,
|
||||||
|
mgmtExpense: 42835, netProfit: 16515, netMargin: 0.0386,
|
||||||
|
detail: { fabric: 170100, accessories: 72900, labor: 72000, overhead: 54000 } },
|
||||||
|
{ type: '直接人工成本', qty: 5000, unitPrice: 85.67, revenue: 428350,
|
||||||
|
directMaterial: 252720, directLabor: 72000, manufacturingCost: 54000, totalCost: 378720,
|
||||||
|
grossProfit: 49630, grossMargin: 0.1159,
|
||||||
|
mgmtExpense: 42835, netProfit: 6795, netMargin: 0.0159,
|
||||||
|
detail: { fabric: 176904, accessories: 75816, labor: 72000, overhead: 54000 } },
|
||||||
|
{ type: '管理成本', qty: 5000, unitPrice: 85.67, revenue: 428350,
|
||||||
|
directMaterial: 236880, directLabor: 61200, manufacturingCost: 54000, totalCost: 352080,
|
||||||
|
grossProfit: 76270, grossMargin: 0.1781,
|
||||||
|
mgmtExpense: 42835, netProfit: 33435, netMargin: 0.0781,
|
||||||
|
detail: { fabric: 165816, accessories: 71064, labor: 61200, overhead: 54000 } },
|
||||||
|
],
|
||||||
|
costBreakdown: [
|
||||||
|
{ category: '原材料', items: [
|
||||||
|
{ label: '外圈', stdCost: 50450, stdPct: 0.1178, purchaseCost: 52465, purchasePct: 0.1225,
|
||||||
|
usageCost: 49190, usagePct: 0.1148, purchaseVsStdAmt: 2015, purchaseVsStdPct: 0.0400,
|
||||||
|
usageVsStdAmt: -1260, usageVsStdPct: -0.0250 },
|
||||||
|
{ label: '内圈', stdCost: 40050, stdPct: 0.0935, purchaseCost: 41648, purchasePct: 0.0972,
|
||||||
|
usageCost: 39058, usagePct: 0.0912, purchaseVsStdAmt: 1598, purchaseVsStdPct: 0.0399,
|
||||||
|
usageVsStdAmt: -992, usageVsStdPct: -0.0248 },
|
||||||
|
{ label: '滚针', stdCost: 114500, stdPct: 0.2673, purchaseCost: 119085, purchasePct: 0.2780,
|
||||||
|
usageCost: 111584, usagePct: 0.2604, purchaseVsStdAmt: 4585, purchaseVsStdPct: 0.0401,
|
||||||
|
usageVsStdAmt: -2916, usageVsStdPct: -0.0255 },
|
||||||
|
{ label: '保持器', stdCost: 15000, stdPct: 0.0350, purchaseCost: 15593, purchasePct: 0.0364,
|
||||||
|
usageCost: 14616, usagePct: 0.0341, purchaseVsStdAmt: 593, purchaseVsStdPct: 0.0395,
|
||||||
|
usageVsStdAmt: -384, usageVsStdPct: -0.0256 },
|
||||||
|
{ label: '挡圈', stdCost: 11200, stdPct: 0.0261, purchaseCost: 11650, purchasePct: 0.0272,
|
||||||
|
usageCost: 10920, usagePct: 0.0255, purchaseVsStdAmt: 450, purchaseVsStdPct: 0.0402,
|
||||||
|
usageVsStdAmt: -280, usageVsStdPct: -0.0250 },
|
||||||
|
{ label: '密封', stdCost: 9300, stdPct: 0.0217, purchaseCost: 9679, purchasePct: 0.0226,
|
||||||
|
usageCost: 9072, usagePct: 0.0212, purchaseVsStdAmt: 379, purchaseVsStdPct: 0.0408,
|
||||||
|
usageVsStdAmt: -228, usageVsStdPct: -0.0245 },
|
||||||
|
{ label: '油塞', stdCost: 2500, stdPct: 0.0058, purchaseCost: 2600, purchasePct: 0.0061,
|
||||||
|
usageCost: 2440, usagePct: 0.0057, purchaseVsStdAmt: 100, purchaseVsStdPct: 0.0400,
|
||||||
|
usageVsStdAmt: -60, usageVsStdPct: -0.0240 },
|
||||||
|
]},
|
||||||
|
{ category: '直接人工', items: [
|
||||||
|
{ label: '人工', stdCost: 72000, stdPct: 0.1681, purchaseCost: 72000, purchasePct: 0.1681,
|
||||||
|
usageCost: 61200, usagePct: 0.1429, purchaseVsStdAmt: 0, purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: -10800, usageVsStdPct: -0.1500 },
|
||||||
|
]},
|
||||||
|
{ category: '其他费用', items: [
|
||||||
|
{ label: '费用', stdCost: 54000, stdPct: 0.1261, purchaseCost: 54000, purchasePct: 0.1261,
|
||||||
|
usageCost: 54000, usagePct: 0.1261, purchaseVsStdAmt: 0, purchaseVsStdPct: 0, usageVsStdAmt: 0, usageVsStdPct: 0 },
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
quoteCostItems: [
|
||||||
|
{ name: '外圈', quotedPrice: 10.09, quantity: 5000, totalCost: 50450, percentage: 0.2076 },
|
||||||
|
{ name: '内圈', quotedPrice: 8.01, quantity: 5000, totalCost: 40050, percentage: 0.1648 },
|
||||||
|
{ name: '滚针', quotedPrice: 1.43125, quantity: 80000, totalCost: 114500, percentage: 0.4712 },
|
||||||
|
{ name: '保持器', quotedPrice: 3.00, quantity: 5000, totalCost: 15000, percentage: 0.0617 },
|
||||||
|
{ name: '挡圈', quotedPrice: 1.12, quantity: 10000, totalCost: 11200, percentage: 0.0461 },
|
||||||
|
{ name: '密封', quotedPrice: 0.93, quantity: 10000, totalCost: 9300, percentage: 0.0383 },
|
||||||
|
{ name: '油塞', quotedPrice: 0.25, quantity: 10000, totalCost: 2500, percentage: 0.0103 },
|
||||||
|
{ name: '人工', quotedPrice: 14.4, quantity: 5000, totalCost: 72000, percentage: 0.1681 },
|
||||||
|
{ name: '制造费用', quotedPrice: 10.8, quantity: 5000, totalCost: 54000, percentage: 0.1261 },
|
||||||
|
],
|
||||||
|
note: '钢材直接人工成本高于标准4%,但领用环节人工节省10800元(15%),整体管理成本最优。建议优化采购渠道降低钢材溢价。',
|
||||||
|
productionLines: DEFAULT_PRODUCTION_LINES,
|
||||||
|
},
|
||||||
|
// ── ORD002: 徐工集团 — 圆锥滚子轴承 32210 ──────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'ORD002', orderNo: 'RB-ZZ-260402002', customer: '徐工集团', salesperson: '林志强',
|
||||||
|
channel: '内销-直销', closedDate: '2026-05-10', productCategory: '圆锥滚子轴承',
|
||||||
|
status: 'closed', riskLevel: 'warning', deliveryPriority: 'high', region: '华东区',
|
||||||
|
tags: ['工程机械', '领用超标', '成本偏差'], submittedAt: '2026-06-09',
|
||||||
|
rows: [
|
||||||
|
{ type: '原材料成本', qty: 3500, unitPrice: 385.0, revenue: 1347500,
|
||||||
|
directMaterial: 756000, directLabor: 231000, manufacturingCost: 168000, totalCost: 1155000,
|
||||||
|
grossProfit: 192500, grossMargin: 0.1429,
|
||||||
|
mgmtExpense: 134750, netProfit: 57750, netMargin: 0.0429,
|
||||||
|
detail: { fabric: 529200, accessories: 226800, labor: 231000, overhead: 168000 } },
|
||||||
|
{ type: '直接人工成本', qty: 3500, unitPrice: 385.0, revenue: 1347500,
|
||||||
|
directMaterial: 793800, directLabor: 231000, manufacturingCost: 168000, totalCost: 1192800,
|
||||||
|
grossProfit: 154700, grossMargin: 0.1148,
|
||||||
|
mgmtExpense: 134750, netProfit: 19950, netMargin: 0.0148,
|
||||||
|
detail: { fabric: 555660, accessories: 238140, labor: 231000, overhead: 168000 } },
|
||||||
|
{ type: '管理成本', qty: 3500, unitPrice: 385.0, revenue: 1347500,
|
||||||
|
directMaterial: 816480, directLabor: 266400, manufacturingCost: 168000, totalCost: 1250880,
|
||||||
|
grossProfit: 96620, grossMargin: 0.0717,
|
||||||
|
mgmtExpense: 134750, netProfit: -38130, netMargin: -0.0283,
|
||||||
|
detail: { fabric: 571536, accessories: 244944, labor: 266400, overhead: 168000 } },
|
||||||
|
],
|
||||||
|
costBreakdown: makeBearingBreakdown(
|
||||||
|
{ directMaterial: 756000, directLabor: 231000 } as CostRow,
|
||||||
|
{ directMaterial: 793800, directLabor: 231000 } as CostRow,
|
||||||
|
{ directMaterial: 816480, directLabor: 266400 } as CostRow,
|
||||||
|
1347500, 168000),
|
||||||
|
note: '管理成本严重超标:材料超8%、人工超15%,导致领用口径净亏损38130元。需重点核查生产损耗率与工时记录。',
|
||||||
|
productionLines: DEFAULT_PRODUCTION_LINES,
|
||||||
|
},
|
||||||
|
// ── ORD003: Siemens AG — 角接触球轴承 7208B ────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'ORD003', orderNo: 'RB-ZZ-260228003', customer: 'Siemens AG', salesperson: '黄海燕',
|
||||||
|
channel: '外销-B2B', closedDate: '2026-04-05', productCategory: '角接触球轴承',
|
||||||
|
status: 'shipped', riskLevel: 'normal', deliveryPriority: 'normal', region: '欧洲区',
|
||||||
|
tags: ['精密轴承', '外销欧洲', '毛利偏薄'], submittedAt: '2026-06-08',
|
||||||
|
rows: [
|
||||||
|
{ type: '原材料成本', qty: 8000, unitPrice: 255.54, revenue: 2044320,
|
||||||
|
directMaterial: 1134000, directLabor: 367200, manufacturingCost: 275400, totalCost: 1776600,
|
||||||
|
grossProfit: 267720, grossMargin: 0.1310,
|
||||||
|
mgmtExpense: 204432, netProfit: 63288, netMargin: 0.0310,
|
||||||
|
detail: { fabric: 793800, accessories: 340200, labor: 367200, overhead: 275400 } },
|
||||||
|
{ type: '直接人工成本', qty: 8000, unitPrice: 255.54, revenue: 2044320,
|
||||||
|
directMaterial: 1095480, directLabor: 367200, manufacturingCost: 275400, totalCost: 1738080,
|
||||||
|
grossProfit: 306240, grossMargin: 0.1498,
|
||||||
|
mgmtExpense: 204432, netProfit: 101808, netMargin: 0.0498,
|
||||||
|
detail: { fabric: 766836, accessories: 328644, labor: 367200, overhead: 275400 } },
|
||||||
|
{ type: '管理成本', qty: 8000, unitPrice: 255.54, revenue: 2044320,
|
||||||
|
directMaterial: 1145340, directLabor: 356040, manufacturingCost: 275400, totalCost: 1776780,
|
||||||
|
grossProfit: 267540, grossMargin: 0.1309,
|
||||||
|
mgmtExpense: 204432, netProfit: 63108, netMargin: 0.0309,
|
||||||
|
detail: { fabric: 801738, accessories: 343602, labor: 356040, overhead: 275400 } },
|
||||||
|
],
|
||||||
|
costBreakdown: makeBearingBreakdown(
|
||||||
|
{ directMaterial: 1134000, directLabor: 367200 } as CostRow,
|
||||||
|
{ directMaterial: 1095480, directLabor: 367200 } as CostRow,
|
||||||
|
{ directMaterial: 1145340, directLabor: 356040 } as CostRow,
|
||||||
|
2044320, 275400),
|
||||||
|
productionLines: DEFAULT_PRODUCTION_LINES,
|
||||||
|
},
|
||||||
|
// ── ORD004: ABB Robotics — 精密轴承套装 ────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'ORD004', orderNo: 'RB-ZZ-260310004', customer: 'ABB Robotics', salesperson: '黄海燕',
|
||||||
|
channel: '外销-B2B', closedDate: '2026-04-18', productCategory: '精密轴承套装',
|
||||||
|
status: 'closed', riskLevel: 'normal', deliveryPriority: 'low', region: '欧洲区',
|
||||||
|
tags: ['精密套装', '高毛利', '成本稳定'], submittedAt: '2026-06-07',
|
||||||
|
rows: [
|
||||||
|
{ type: '原材料成本', qty: 3200, unitPrice: 680.0, revenue: 2176000,
|
||||||
|
directMaterial: 1088000, directLabor: 326400, manufacturingCost: 272000, totalCost: 1686400,
|
||||||
|
grossProfit: 489600, grossMargin: 0.2250,
|
||||||
|
mgmtExpense: 217600, netProfit: 272000, netMargin: 0.1250,
|
||||||
|
detail: { fabric: 761600, accessories: 326400, labor: 326400, overhead: 272000 } },
|
||||||
|
{ type: '直接人工成本', qty: 3200, unitPrice: 680.0, revenue: 2176000,
|
||||||
|
directMaterial: 1040160, directLabor: 326400, manufacturingCost: 272000, totalCost: 1638560,
|
||||||
|
grossProfit: 537440, grossMargin: 0.2470,
|
||||||
|
mgmtExpense: 217600, netProfit: 319840, netMargin: 0.1470,
|
||||||
|
detail: { fabric: 728112, accessories: 312048, labor: 326400, overhead: 272000 } },
|
||||||
|
{ type: '管理成本', qty: 3200, unitPrice: 680.0, revenue: 2176000,
|
||||||
|
directMaterial: 1121680, directLabor: 316560, manufacturingCost: 272000, totalCost: 1710240,
|
||||||
|
grossProfit: 465760, grossMargin: 0.2140,
|
||||||
|
mgmtExpense: 217600, netProfit: 248160, netMargin: 0.1140,
|
||||||
|
detail: { fabric: 785176, accessories: 336504, labor: 316560, overhead: 272000 } },
|
||||||
|
],
|
||||||
|
costBreakdown: makeBearingBreakdown(
|
||||||
|
{ directMaterial: 1088000, directLabor: 326400 } as CostRow,
|
||||||
|
{ directMaterial: 1040160, directLabor: 326400 } as CostRow,
|
||||||
|
{ directMaterial: 1121680, directLabor: 316560 } as CostRow,
|
||||||
|
2176000, 272000),
|
||||||
|
productionLines: DEFAULT_PRODUCTION_LINES,
|
||||||
|
},
|
||||||
|
// ── ORD005: 海螺水泥 — 调心滚子轴承 22316 ──────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'ORD005', orderNo: 'RB-ZZ-260120005', customer: '海螺水泥', salesperson: '林志强',
|
||||||
|
channel: '内销-代理', closedDate: '2026-03-28', productCategory: '调心滚子轴承',
|
||||||
|
status: 'closed', riskLevel: 'normal', deliveryPriority: 'low', region: '华南区',
|
||||||
|
tags: ['重工配套', '高毛利', '代理渠道'], submittedAt: '2026-06-06',
|
||||||
|
rows: [
|
||||||
|
{ type: '原材料成本', qty: 2000, unitPrice: 645.0, revenue: 1290000,
|
||||||
|
directMaterial: 504000, directLabor: 168000, manufacturingCost: 126000, totalCost: 798000,
|
||||||
|
grossProfit: 492000, grossMargin: 0.3814,
|
||||||
|
mgmtExpense: 193500, netProfit: 298500, netMargin: 0.2314,
|
||||||
|
detail: { fabric: 352800, accessories: 151200, labor: 168000, overhead: 126000 } },
|
||||||
|
{ type: '直接人工成本', qty: 2000, unitPrice: 645.0, revenue: 1290000,
|
||||||
|
directMaterial: 496440, directLabor: 168000, manufacturingCost: 126000, totalCost: 790440,
|
||||||
|
grossProfit: 499560, grossMargin: 0.3873,
|
||||||
|
mgmtExpense: 193500, netProfit: 306060, netMargin: 0.2373,
|
||||||
|
detail: { fabric: 347508, accessories: 148932, labor: 168000, overhead: 126000 } },
|
||||||
|
{ type: '管理成本', qty: 2000, unitPrice: 645.0, revenue: 1290000,
|
||||||
|
directMaterial: 501480, directLabor: 164640, manufacturingCost: 126000, totalCost: 792120,
|
||||||
|
grossProfit: 497880, grossMargin: 0.3860,
|
||||||
|
mgmtExpense: 193500, netProfit: 304380, netMargin: 0.2360,
|
||||||
|
detail: { fabric: 351036, accessories: 150444, labor: 164640, overhead: 126000 } },
|
||||||
|
],
|
||||||
|
costBreakdown: makeBearingBreakdown(
|
||||||
|
{ directMaterial: 504000, directLabor: 168000 } as CostRow,
|
||||||
|
{ directMaterial: 496440, directLabor: 168000 } as CostRow,
|
||||||
|
{ directMaterial: 501480, directLabor: 164640 } as CostRow,
|
||||||
|
1290000, 126000),
|
||||||
|
productionLines: DEFAULT_PRODUCTION_LINES,
|
||||||
|
},
|
||||||
|
// ── ORD006: Thyssenkrupp — 推力滚子轴承 ───────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'ORD006', orderNo: 'RB-ZZ-260205006', customer: 'Thyssenkrupp', salesperson: '黄海燕',
|
||||||
|
channel: '外销-B2B', closedDate: '2026-03-22', productCategory: '推力滚子轴承',
|
||||||
|
status: 'shipped', riskLevel: 'normal', deliveryPriority: 'normal', region: '欧洲区',
|
||||||
|
tags: ['重工配套', '外销欧洲', '高毛利'], submittedAt: '2026-06-05',
|
||||||
|
rows: [
|
||||||
|
{ type: '原材料成本', qty: 2400, unitPrice: 560.0, revenue: 1344000,
|
||||||
|
directMaterial: 604800, directLabor: 201600, manufacturingCost: 161280, totalCost: 967680,
|
||||||
|
grossProfit: 376320, grossMargin: 0.2800,
|
||||||
|
mgmtExpense: 134400, netProfit: 241920, netMargin: 0.1800,
|
||||||
|
detail: { fabric: 423360, accessories: 181440, labor: 201600, overhead: 161280 } },
|
||||||
|
{ type: '直接人工成本', qty: 2400, unitPrice: 560.0, revenue: 1344000,
|
||||||
|
directMaterial: 583680, directLabor: 201600, manufacturingCost: 161280, totalCost: 946560,
|
||||||
|
grossProfit: 397440, grossMargin: 0.2957,
|
||||||
|
mgmtExpense: 134400, netProfit: 263040, netMargin: 0.1957,
|
||||||
|
detail: { fabric: 408576, accessories: 175104, labor: 201600, overhead: 161280 } },
|
||||||
|
{ type: '管理成本', qty: 2400, unitPrice: 560.0, revenue: 1344000,
|
||||||
|
directMaterial: 592800, directLabor: 197568, manufacturingCost: 161280, totalCost: 951648,
|
||||||
|
grossProfit: 392352, grossMargin: 0.2919,
|
||||||
|
mgmtExpense: 134400, netProfit: 257952, netMargin: 0.1919,
|
||||||
|
detail: { fabric: 414960, accessories: 177840, labor: 197568, overhead: 161280 } },
|
||||||
|
],
|
||||||
|
costBreakdown: makeBearingBreakdown(
|
||||||
|
{ directMaterial: 604800, directLabor: 201600 } as CostRow,
|
||||||
|
{ directMaterial: 583680, directLabor: 201600 } as CostRow,
|
||||||
|
{ directMaterial: 592800, directLabor: 197568 } as CostRow,
|
||||||
|
1344000, 161280),
|
||||||
|
productionLines: DEFAULT_PRODUCTION_LINES,
|
||||||
|
},
|
||||||
|
// ── ORD007: Vestas — 风电主轴轴承 ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'ORD007', orderNo: 'RB-ZZ-260115007', customer: 'Vestas', salesperson: '黄海燕',
|
||||||
|
channel: '外销-B2B', closedDate: '2026-03-10', productCategory: '风电主轴轴承',
|
||||||
|
status: 'closed', riskLevel: 'warning', deliveryPriority: 'high', region: '欧洲区',
|
||||||
|
tags: ['风电核心件', '高单价', '毛利监控'], submittedAt: '2026-06-04',
|
||||||
|
rows: [
|
||||||
|
{ type: '原材料成本', qty: 600, unitPrice: 3600.0, revenue: 2160000,
|
||||||
|
directMaterial: 1080000, directLabor: 324000, manufacturingCost: 216000, totalCost: 1620000,
|
||||||
|
grossProfit: 540000, grossMargin: 0.2500,
|
||||||
|
mgmtExpense: 324000, netProfit: 216000, netMargin: 0.1000,
|
||||||
|
detail: { fabric: 756000, accessories: 324000, labor: 324000, overhead: 216000 } },
|
||||||
|
{ type: '直接人工成本', qty: 600, unitPrice: 3600.0, revenue: 2160000,
|
||||||
|
directMaterial: 1036800, directLabor: 324000, manufacturingCost: 216000, totalCost: 1576800,
|
||||||
|
grossProfit: 583200, grossMargin: 0.2700,
|
||||||
|
mgmtExpense: 324000, netProfit: 259200, netMargin: 0.1200,
|
||||||
|
detail: { fabric: 725760, accessories: 311040, labor: 324000, overhead: 216000 } },
|
||||||
|
{ type: '管理成本', qty: 600, unitPrice: 3600.0, revenue: 2160000,
|
||||||
|
directMaterial: 1112400, directLabor: 333720, manufacturingCost: 216000, totalCost: 1662120,
|
||||||
|
grossProfit: 497880, grossMargin: 0.2305,
|
||||||
|
mgmtExpense: 324000, netProfit: 173880, netMargin: 0.0805,
|
||||||
|
detail: { fabric: 778680, accessories: 333720, labor: 333720, overhead: 216000 } },
|
||||||
|
],
|
||||||
|
costBreakdown: makeBearingBreakdown(
|
||||||
|
{ directMaterial: 1080000, directLabor: 324000 } as CostRow,
|
||||||
|
{ directMaterial: 1036800, directLabor: 324000 } as CostRow,
|
||||||
|
{ directMaterial: 1112400, directLabor: 333720 } as CostRow,
|
||||||
|
2160000, 216000),
|
||||||
|
note: '风电主轴轴承单价高但材料占比大。采购节省4%但领用超标3%,人工领用也偏高,需关注特种钢材加工损耗。',
|
||||||
|
productionLines: DEFAULT_PRODUCTION_LINES,
|
||||||
|
},
|
||||||
|
// ── ORD008: 中车株洲电机 — 绝缘轴承 ──────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'ORD008', orderNo: 'RB-ZZ-260301008', customer: '中车株洲电机', salesperson: '林志强',
|
||||||
|
channel: '内销-直销', closedDate: '', productCategory: '绝缘轴承',
|
||||||
|
status: 'production', riskLevel: 'normal', deliveryPriority: 'urgent', region: '华中区',
|
||||||
|
tags: ['轨道交通', '在产', '高毛利'], submittedAt: '2026-06-03',
|
||||||
|
rows: [
|
||||||
|
{ type: '原材料成本', qty: 1800, unitPrice: 420.0, revenue: 756000,
|
||||||
|
directMaterial: 302400, directLabor: 113400, manufacturingCost: 75600, totalCost: 491400,
|
||||||
|
grossProfit: 264600, grossMargin: 0.3500,
|
||||||
|
mgmtExpense: 75600, netProfit: 189000, netMargin: 0.2500,
|
||||||
|
detail: { fabric: 211680, accessories: 90720, labor: 113400, overhead: 75600 } },
|
||||||
|
{ type: '直接人工成本', qty: 1800, unitPrice: 420.0, revenue: 756000,
|
||||||
|
directMaterial: 289440, directLabor: 113400, manufacturingCost: 75600, totalCost: 478440,
|
||||||
|
grossProfit: 277560, grossMargin: 0.3671,
|
||||||
|
mgmtExpense: 75600, netProfit: 201960, netMargin: 0.2671,
|
||||||
|
detail: { fabric: 202608, accessories: 86832, labor: 113400, overhead: 75600 } },
|
||||||
|
{ type: '管理成本', qty: 1800, unitPrice: 420.0, revenue: 756000,
|
||||||
|
directMaterial: 308520, directLabor: 116820, manufacturingCost: 75600, totalCost: 500940,
|
||||||
|
grossProfit: 255060, grossMargin: 0.3374,
|
||||||
|
mgmtExpense: 75600, netProfit: 179460, netMargin: 0.2374,
|
||||||
|
detail: { fabric: 215964, accessories: 92556, labor: 116820, overhead: 75600 } },
|
||||||
|
],
|
||||||
|
costBreakdown: makeBearingBreakdown(
|
||||||
|
{ directMaterial: 302400, directLabor: 113400 } as CostRow,
|
||||||
|
{ directMaterial: 289440, directLabor: 113400 } as CostRow,
|
||||||
|
{ directMaterial: 308520, directLabor: 116820 } as CostRow,
|
||||||
|
756000, 75600),
|
||||||
|
productionLines: DEFAULT_PRODUCTION_LINES,
|
||||||
|
},
|
||||||
|
// ── 批量生成的订单 (ORD009–ORD020) ───────────────────────────────────
|
||||||
|
genOrder({ id:'ORD009', orderNo:'RB-ZZ-260220009', customer:'SEW-Eurodrive', salesperson:'黄海燕', channel:'外销-B2B', closedDate:'2026-04-15', productCategory:'深沟球轴承', status:'closed', riskLevel:'normal', region:'欧洲区', tags:['电机配套','外销稳定'], submittedAt:'2026-06-02', qty:15000, unitPrice:42.5, stdMat:320000, stdLabor:96000, stdOH:72000, purMatAdj:-0.03, useMatAdj:-0.01, useLaborAdj:-0.02, mgmt:64000 }),
|
||||||
|
genOrder({ id:'ORD010', orderNo:'RB-ZZ-260105010', customer:'美的集团', salesperson:'林志强', channel:'内销-直销', closedDate:'2026-03-15', productCategory:'深沟球轴承', status:'closed', riskLevel:'warning', region:'华南区', tags:['家电配套','毛利偏低'], submittedAt:'2026-06-01', qty:50000, unitPrice:12.8, stdMat:320000, stdLabor:96000, stdOH:72000, purMatAdj:-0.02, useMatAdj:0.05, useLaborAdj:0.03, mgmt:48000, note:'大批量低单价,领用材料损耗率偏高。' }),
|
||||||
|
genOrder({ id:'ORD011', orderNo:'RB-ZZ-260210011', customer:'Bosch Rexroth', salesperson:'黄海燕', channel:'外销-B2B', closedDate:'2026-04-28', productCategory:'圆锥滚子轴承', status:'closed', riskLevel:'normal', region:'欧洲区', tags:['液压配套','高毛利'], submittedAt:'2026-05-30', qty:6000, unitPrice:185.0, stdMat:580000, stdLabor:168000, stdOH:126000, purMatAdj:-0.04, useMatAdj:-0.02, useLaborAdj:-0.03, mgmt:110000 }),
|
||||||
|
genOrder({ id:'ORD012', orderNo:'RB-ZZ-260318012', customer:'中联重科', salesperson:'陈伟明', channel:'内销-直销', closedDate:'2026-05-20', productCategory:'调心滚子轴承', status:'shipped', riskLevel:'normal', region:'华中区', tags:['工程机械','内销主力'], submittedAt:'2026-05-28', qty:4000, unitPrice:520.0, stdMat:1080000, stdLabor:312000, stdOH:234000, purMatAdj:-0.02, useMatAdj:0.01, useLaborAdj:-0.02, mgmt:208000 }),
|
||||||
|
genOrder({ id:'ORD013', orderNo:'RB-ZZ-260201013', customer:'Nidec Japan', salesperson:'黄海燕', channel:'外销-B2B', closedDate:'2026-04-08', productCategory:'精密轴承套装', status:'closed', riskLevel:'normal', region:'日本区', tags:['电机配套','外销日本'], submittedAt:'2026-05-26', qty:8000, unitPrice:265.0, stdMat:1120000, stdLabor:320000, stdOH:240000, purMatAdj:-0.03, useMatAdj:-0.01, useLaborAdj:-0.02, mgmt:168000 }),
|
||||||
|
genOrder({ id:'ORD014', orderNo:'RB-ZZ-260322014', customer:'金风科技', salesperson:'陈伟明', channel:'内销-直销', closedDate:'2026-05-28', productCategory:'风电偏航轴承', status:'closed', riskLevel:'danger', region:'西北区', tags:['风电核心件','高风险','领用超标'], submittedAt:'2026-05-24', qty:400, unitPrice:8500.0, stdMat:1760000, stdLabor:480000, stdOH:360000, purMatAdj:-0.02, useMatAdj:0.08, useLaborAdj:0.05, mgmt:280000, note:'风电大件加工损耗严重,领用超标8%,需改善工艺管控。' }),
|
||||||
|
genOrder({ id:'ORD015', orderNo:'RB-ZZ-260128015', customer:'柳工机械', salesperson:'林志强', channel:'内销-直销', closedDate:'2026-04-02', productCategory:'圆锥滚子轴承', status:'closed', riskLevel:'warning', region:'华南区', tags:['工程机械','价格压力'], submittedAt:'2026-05-22', qty:10000, unitPrice:280.0, stdMat:1560000, stdLabor:420000, stdOH:336000, purMatAdj:-0.01, useMatAdj:0.04, useLaborAdj:0.02, mgmt:224000, note:'客户压价严重,领用偏差导致毛利空间被压缩。' }),
|
||||||
|
genOrder({ id:'ORD016', orderNo:'RB-ZZ-260410016', customer:'SKF Brasil', salesperson:'黄海燕', channel:'外销-B2B', closedDate:'', productCategory:'深沟球轴承', status:'production', riskLevel:'normal', region:'南美区', tags:['OEM配套','在产'], submittedAt:'2026-05-20', qty:20000, unitPrice:38.5, stdMat:420000, stdLabor:126000, stdOH:94500, purMatAdj:-0.02, useMatAdj:0.01, useLaborAdj:0.0, mgmt:64000 }),
|
||||||
|
genOrder({ id:'ORD017', orderNo:'RB-ZZ-260215017', customer:'现代重工', salesperson:'黄海燕', channel:'外销-B2B', closedDate:'2026-03-25', productCategory:'船用轴承', status:'closed', riskLevel:'danger', region:'韩国区', tags:['船舶配套','亏损风险'], submittedAt:'2026-05-18', qty:3000, unitPrice:420.0, stdMat:680000, stdLabor:189000, stdOH:151200, purMatAdj:0.03, useMatAdj:0.10, useLaborAdj:0.06, mgmt:126000, note:'船级社认证材料溢价3%,领用超标10%,亏损风险大。' }),
|
||||||
|
genOrder({ id:'ORD018', orderNo:'RB-ZZ-260305018', customer:'卧龙电驱', salesperson:'陈伟明', channel:'内销-代理', closedDate:'2026-04-22', productCategory:'深沟球轴承', status:'closed', riskLevel:'normal', region:'华东区', tags:['电机配套','成本稳定'], submittedAt:'2026-05-16', qty:30000, unitPrice:22.5, stdMat:360000, stdLabor:108000, stdOH:81000, purMatAdj:-0.03, useMatAdj:-0.01, useLaborAdj:-0.02, mgmt:54000 }),
|
||||||
|
genOrder({ id:'ORD019', orderNo:'RB-ZZ-260405019', customer:'格力电器', salesperson:'林志强', channel:'内销-直销', closedDate:'', productCategory:'深沟球轴承', status:'open', riskLevel:'normal', region:'华南区', tags:['家电配套','在制'], submittedAt:'2026-05-14', qty:80000, unitPrice:8.5, stdMat:360000, stdLabor:108000, stdOH:81000, purMatAdj:-0.02, useMatAdj:0.01, useLaborAdj:0.0, mgmt:48000 }),
|
||||||
|
genOrder({ id:'ORD020', orderNo:'RB-ZZ-260328020', customer:'WEG Brazil', salesperson:'黄海燕', channel:'外销-B2B', closedDate:'2026-05-15', productCategory:'OEM轴承', status:'closed', riskLevel:'warning', region:'南美区', tags:['OEM配套','偏差监控'], submittedAt:'2026-05-12', qty:25000, unitPrice:28.0, stdMat:380000, stdLabor:112000, stdOH:84000, purMatAdj:0.01, useMatAdj:0.06, useLaborAdj:0.03, mgmt:56000, note:'巴西批量大但单价偏低,领用材料偏差6%需持续监控。' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const statusConfig: Record<OrderStatus, { label: string; color: string }> = {
|
||||||
|
open: { label: '在制中', color: '#00d4ff' },
|
||||||
|
closed: { label: '已结单', color: '#00e599' },
|
||||||
|
production: { label: '生产中', color: '#ffb340' },
|
||||||
|
shipped: { label: '已出货', color: '#7b61ff' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const riskConfig: Record<RiskLevel, { label: string; color: string; bg: string }> = {
|
||||||
|
normal: { label: '正常', color: '#00e599', bg: 'rgba(0,229,153,0.12)' },
|
||||||
|
warning: { label: '预警', color: '#ffb340', bg: 'rgba(255,179,64,0.12)' },
|
||||||
|
danger: { label: '危险', color: '#ff6b6b', bg: 'rgba(255,107,107,0.12)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const channelConfig: Record<Channel, { color: string }> = {
|
||||||
|
'外销-B2B': { color: '#00d4ff' },
|
||||||
|
'外销-B2C': { color: '#4fc3f7' },
|
||||||
|
'内销-直销': { color: '#7b61ff' },
|
||||||
|
'内销-代理': { color: '#ff6b9d' },
|
||||||
|
'电商平台': { color: '#ffb340' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deliveryPriorityConfig: Record<DeliveryPriority, { label: string; color: string; bg: string }> = {
|
||||||
|
urgent: { label: '紧急', color: '#ff6b6b', bg: 'rgba(255,107,107,0.15)' },
|
||||||
|
high: { label: '高', color: '#ffb340', bg: 'rgba(255,179,64,0.15)' },
|
||||||
|
normal: { label: '中', color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
||||||
|
low: { label: '低', color: '#00e599', bg: 'rgba(0,229,153,0.12)' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// 温州市AI翻译官数据
|
||||||
|
export type TranslatorType = 'vendor' | 'university' | 'enterprise';
|
||||||
|
export type ExpertiseArea = '工业视觉' | '预测维护' | '智能排产' | '工艺优化' | '供应链AI' | '能源管理' | '质量管控' | '数字孪生';
|
||||||
|
|
||||||
|
export interface Translator {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
organization: string;
|
||||||
|
orgType: TranslatorType;
|
||||||
|
avatar: string;
|
||||||
|
expertise: ExpertiseArea[];
|
||||||
|
industries: string[];
|
||||||
|
rating: number;
|
||||||
|
cases: number;
|
||||||
|
bio: string;
|
||||||
|
color: string;
|
||||||
|
accentColor: string;
|
||||||
|
availability: 'available' | 'busy' | 'offline';
|
||||||
|
certifications: string[];
|
||||||
|
matchedDemands?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRANSLATORS: Translator[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '林启航',
|
||||||
|
title: '温州智能制造AI翻译官',
|
||||||
|
organization: '温州市人工智能局专家库',
|
||||||
|
orgType: 'vendor',
|
||||||
|
avatar: '林',
|
||||||
|
expertise: ['智能排产', '工业视觉', '质量管控'],
|
||||||
|
industries: ['智能电气', '工业泵阀', '汽车零部件'],
|
||||||
|
rating: 4.9,
|
||||||
|
cases: 39,
|
||||||
|
bio: '长期服务温州智能电气、泵阀和汽配企业,擅长把“排产难、质检慢、数据散”等业务问题翻译成可交付的AI项目边界、数据清单和验收KPI。',
|
||||||
|
color: '#00d4ff',
|
||||||
|
accentColor: '#0099cc',
|
||||||
|
availability: 'available',
|
||||||
|
certifications: ['温州市AI+制造场景评审专家', '工业互联网实施顾问'],
|
||||||
|
matchedDemands: ['WZ004', 'WZ005', 'WZ006', 'WZ023'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '陈若岚',
|
||||||
|
title: '工业视觉与质量数据专家',
|
||||||
|
organization: '温州大学智能制造研究团队',
|
||||||
|
orgType: 'university',
|
||||||
|
avatar: '陈',
|
||||||
|
expertise: ['工业视觉', '质量管控', '工艺优化'],
|
||||||
|
industries: ['鞋业服装', '眼镜', '智能装备'],
|
||||||
|
rating: 4.8,
|
||||||
|
cases: 24,
|
||||||
|
bio: '专注机器视觉、小样本缺陷识别和质量闭环分析,适合金帝鞋业、红润光学、三星机电等质检类场景的样本体系设计和算法路线评审。',
|
||||||
|
color: '#ff6b9d',
|
||||||
|
accentColor: '#cc4477',
|
||||||
|
availability: 'available',
|
||||||
|
certifications: ['机器视觉应用专家', '温州制造业质量数字化顾问'],
|
||||||
|
matchedDemands: ['WZ003', 'WZ016', 'WZ026', 'WZ028'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '周立新',
|
||||||
|
title: '智能电气甲方实践专家',
|
||||||
|
organization: '乐清智能电气产业顾问组',
|
||||||
|
orgType: 'enterprise',
|
||||||
|
avatar: '周',
|
||||||
|
expertise: ['智能排产', '供应链AI', '数字孪生'],
|
||||||
|
industries: ['智能电气', '新能源'],
|
||||||
|
rating: 4.7,
|
||||||
|
cases: 31,
|
||||||
|
bio: '来自温州智能电气产业一线,熟悉低压电器、变压器、电表、充电桩等产品的设计、制造、供应链和MES落地约束,最懂甲方业务语言。',
|
||||||
|
color: '#7b61ff',
|
||||||
|
accentColor: '#5540dd',
|
||||||
|
availability: 'busy',
|
||||||
|
certifications: ['智能电气行业数字化转型顾问', '精益生产与APS实践顾问'],
|
||||||
|
matchedDemands: ['WZ002', 'WZ004', 'WZ021', 'WZ022', 'WZ025'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: '吴嘉禾',
|
||||||
|
title: '鞋服与消费品AI场景专家',
|
||||||
|
organization: '温州鞋服产业数字化服务联盟',
|
||||||
|
orgType: 'vendor',
|
||||||
|
avatar: '吴',
|
||||||
|
expertise: ['智能排产', '供应链AI', '工艺优化'],
|
||||||
|
industries: ['鞋业服装', '食品农产品', '眼镜'],
|
||||||
|
rating: 4.6,
|
||||||
|
cases: 42,
|
||||||
|
bio: '服务温州鞋业、服装、食品连锁和眼镜企业,擅长小单快反、AI设计、门店运营、预测配补货和全渠道营销场景的ROI测算。',
|
||||||
|
color: '#ff8c42',
|
||||||
|
accentColor: '#dd6620',
|
||||||
|
availability: 'available',
|
||||||
|
certifications: ['鞋服产业数字化顾问', '零售数据分析顾问'],
|
||||||
|
matchedDemands: ['WZ007', 'WZ010', 'WZ027'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: '叶明澈',
|
||||||
|
title: '设备预测维护与远程运维专家',
|
||||||
|
organization: '温州工业互联网公共服务平台',
|
||||||
|
orgType: 'vendor',
|
||||||
|
avatar: '叶',
|
||||||
|
expertise: ['预测维护', '数字孪生', '能源管理'],
|
||||||
|
industries: ['新能源', '智能装备', '新材料', '低空经济'],
|
||||||
|
rating: 4.8,
|
||||||
|
cases: 28,
|
||||||
|
bio: '擅长设备运行时序数据建模、远程运维、能耗分析和数字孪生验证,适合瑞浦兰钧、金田塑业、之翼通航等连续生产与运维场景。',
|
||||||
|
color: '#00e599',
|
||||||
|
accentColor: '#00aa70',
|
||||||
|
availability: 'available',
|
||||||
|
certifications: ['工业互联网平台架构师', '设备健康管理顾问'],
|
||||||
|
matchedDemands: ['WZ001', 'WZ014', 'WZ019'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: '赵思衡',
|
||||||
|
title: '企业知识库与智能体架构师',
|
||||||
|
organization: '温州AI服务商联合体',
|
||||||
|
orgType: 'vendor',
|
||||||
|
avatar: '赵',
|
||||||
|
expertise: ['供应链AI', '质量管控', '工艺优化'],
|
||||||
|
industries: ['工业泵阀', '智能装备', '智能电气'],
|
||||||
|
rating: 4.7,
|
||||||
|
cases: 35,
|
||||||
|
bio: '专注RAG知识库、标书智能体、数据问答、企业数字员工和业务流程编排,适合炜冈科技、超达阀门、安通阀门、益坤电气等知识密集场景。',
|
||||||
|
color: '#4fc3f7',
|
||||||
|
accentColor: '#0090cc',
|
||||||
|
availability: 'busy',
|
||||||
|
certifications: ['企业智能体架构师', '数据治理与知识工程顾问'],
|
||||||
|
matchedDemands: ['WZ008', 'WZ011', 'WZ012', 'WZ013', 'WZ020'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const orgTypeConfig: Record<TranslatorType, { label: string; color: string; bg: string }> = {
|
||||||
|
vendor: { label: '服务商', color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
|
||||||
|
university: { label: '高校院所', color: '#7b61ff', bg: 'rgba(123,97,255,0.12)' },
|
||||||
|
enterprise: { label: '制造甲方', color: '#00e599', bg: 'rgba(0,229,153,0.12)' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ModuleConfig } from '../registry';
|
||||||
|
|
||||||
|
export const moduleConfig: ModuleConfig = {
|
||||||
|
id: 'order-cost-analysis',
|
||||||
|
name: '订单成本枢纽平台',
|
||||||
|
description: '雷勃动力传动订单成本智能分析:理论成本/实际模拟/成本对比/异常归因',
|
||||||
|
icon: 'ReceiptText',
|
||||||
|
path: '/order-cost-analysis',
|
||||||
|
accentColor: '#00d4ff',
|
||||||
|
accentColorLight: '#1a56db',
|
||||||
|
gradient: 'linear-gradient(135deg, #001a4a, #003366, #004488)',
|
||||||
|
gradientLight: 'linear-gradient(135deg, #f0f7ff, #e8f0fe, #dbeafe)',
|
||||||
|
tags: ['订单', '成本', 'AI'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import StarBackground from '../components/StarBackground';
|
||||||
|
import AIMHeader from '../components/AIMHeader';
|
||||||
|
import LeftDemandPanel from '../components/LeftDemandPanel';
|
||||||
|
import CenterDemandDetail from '../components/CenterDemandDetail';
|
||||||
|
import RightTranslatorPanel from '../components/RightTranslatorPanel';
|
||||||
|
import BottomAIBar from '../components/BottomAIBar';
|
||||||
|
import type { AIDemand } from '../data/demands';
|
||||||
|
import type { Translator } from '../data/translators';
|
||||||
|
|
||||||
|
export default function AIMCockpit() {
|
||||||
|
const [selectedDemand, setSelectedDemand] = useState<AIDemand | null>(null);
|
||||||
|
const [selectedTranslator, setSelectedTranslator] = useState<Translator | null>(null);
|
||||||
|
const [leftExpanded, setLeftExpanded] = useState(false);
|
||||||
|
const [rightExpanded, setRightExpanded] = useState(false);
|
||||||
|
const [workspaceResetKey, setWorkspaceResetKey] = useState(0);
|
||||||
|
|
||||||
|
const handleDemandSelect = (d: AIDemand) => {
|
||||||
|
setSelectedDemand(d);
|
||||||
|
setSelectedTranslator(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTranslatorSelect = (t: Translator) =>
|
||||||
|
setSelectedTranslator(prev => prev?.id === t.id ? null : t);
|
||||||
|
|
||||||
|
const resetWorkspace = () => {
|
||||||
|
setSelectedDemand(null);
|
||||||
|
setSelectedTranslator(null);
|
||||||
|
setWorkspaceResetKey(key => key + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100vw', height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: "-apple-system, 'SF Pro Text', 'PingFang SC', sans-serif",
|
||||||
|
color: '#a8d8ff',
|
||||||
|
}}>
|
||||||
|
{/* 星空背景 */}
|
||||||
|
<StarBackground />
|
||||||
|
|
||||||
|
{/* 内容层 */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
|
||||||
|
{/* 顶部标题栏 */}
|
||||||
|
<AIMHeader />
|
||||||
|
|
||||||
|
{/* 留白 */}
|
||||||
|
<div style={{ height: 14, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 中部三栏区域 */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', minHeight: 0, position: 'relative', gap: 0, padding: '0 0' }}>
|
||||||
|
|
||||||
|
{/* 左侧:企业AI需求面板,顶部留出80px */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', flexShrink: 0,
|
||||||
|
marginTop: 80, height: '100%',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
width: leftExpanded ? 508 : 288,
|
||||||
|
}}>
|
||||||
|
<LeftDemandPanel
|
||||||
|
selectedId={selectedDemand?.id ?? null}
|
||||||
|
onSelect={handleDemandSelect}
|
||||||
|
onExpandChange={setLeftExpanded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 间隙 */}
|
||||||
|
<div style={{ width: 14, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 中央:需求详情区 */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', position: 'relative', marginTop: 36 }}>
|
||||||
|
{/* 全息边框容器 */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, borderRadius: 16, overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(0,212,255,0.28)',
|
||||||
|
boxShadow: '0 0 30px rgba(0,212,255,0.1), 0 0 60px rgba(0,100,200,0.06), inset 0 0 30px rgba(0,30,80,0.15)',
|
||||||
|
background: 'rgba(0,15,45,0.55)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* 四角装饰 */}
|
||||||
|
{['top-left', 'top-right', 'bottom-left', 'bottom-right'].map(pos => {
|
||||||
|
const isTop = pos.includes('top');
|
||||||
|
const isLeft = pos.includes('left');
|
||||||
|
return (
|
||||||
|
<div key={pos} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: isTop ? 8 : undefined,
|
||||||
|
bottom: !isTop ? 8 : undefined,
|
||||||
|
left: isLeft ? 8 : undefined,
|
||||||
|
right: !isLeft ? 8 : undefined,
|
||||||
|
width: 16, height: 16, zIndex: 5, pointerEvents: 'none',
|
||||||
|
borderTop: isTop ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
borderBottom: !isTop ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
borderLeft: isLeft ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
borderRight: !isLeft ? '2px solid rgba(0,212,255,0.6)' : 'none',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 中央标题栏 */}
|
||||||
|
<div style={{ padding: '12px 16px 10px', borderBottom: '1px solid rgba(0,212,255,0.1)', display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', gap: 10, flexShrink: 0 }}>
|
||||||
|
<div />
|
||||||
|
<span style={{ color: '#a8d8ff', fontSize: 21, fontWeight: 800, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center' }}>
|
||||||
|
智创空间 Agentic Space
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
onClick={resetWorkspace}
|
||||||
|
title="点击返回智创空间初始界面"
|
||||||
|
style={{ alignSelf: 'stretch', cursor: 'pointer', display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{selectedTranslator ? (
|
||||||
|
<span style={{ padding: '3px 9px', borderRadius: 7, background: 'rgba(123,97,255,0.08)', border: '1px solid rgba(123,97,255,0.2)', color: '#7b61ff', fontSize: 12, fontWeight: 700 }}>
|
||||||
|
@{selectedTranslator.name}
|
||||||
|
</span>
|
||||||
|
) : selectedDemand && (
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: 6, background: 'rgba(0,212,255,0.06)', border: '1px solid rgba(0,212,255,0.15)', color: '#00d4ff', fontSize: 12 }}>
|
||||||
|
{selectedDemand.scenario}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中央内容 */}
|
||||||
|
<CenterDemandDetail demand={selectedDemand} translator={selectedTranslator} resetKey={workspaceResetKey} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 间隙 */}
|
||||||
|
<div style={{ width: 36, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 右侧:AI翻译官面板 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', flexShrink: 0,
|
||||||
|
marginTop: 80, height: '100%',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
width: rightExpanded ? 508 : 288,
|
||||||
|
}}>
|
||||||
|
<RightTranslatorPanel
|
||||||
|
onTranslatorSelect={handleTranslatorSelect}
|
||||||
|
selectedId={selectedTranslator?.id ?? null}
|
||||||
|
onExpandChange={setRightExpanded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部留白 */}
|
||||||
|
<div style={{ height: 12, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 底部智能问答工作区 */}
|
||||||
|
<BottomAIBar
|
||||||
|
selectedDemand={selectedDemand}
|
||||||
|
selectedTranslator={selectedTranslator}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { useState, useEffect, useRef, type DragEvent } from 'react';
|
||||||
|
import StarBackground from '../components/StarBackground';
|
||||||
|
import CostHeader from '../components/CostHeader';
|
||||||
|
import LeftOrderPanel from '../components/LeftOrderPanel';
|
||||||
|
import CenterBOMView from '../components/CenterBOMView';
|
||||||
|
import CenterTheoryCost from '../components/CenterTheoryCost';
|
||||||
|
import CenterProductionSim from '../components/CenterProductionSim';
|
||||||
|
import CenterCostCompare from '../components/CenterCostCompare';
|
||||||
|
import RightAIPanel from '../components/RightAIPanel';
|
||||||
|
import BottomCostBar from '../components/BottomCostBar';
|
||||||
|
import type { Order } from '../data/orders';
|
||||||
|
import type { Equipment } from '../components/CenterProductionSim';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import { useOrders } from '../services/hooks/useOrders';
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ id: 'decompose', icon: '🔍', label: '拆解客户需求产品' },
|
||||||
|
{ id: 'theory', icon: '📐', label: '智能测算理论成本' },
|
||||||
|
{ id: 'profit', icon: '📊', label: '智能模拟实际成本' },
|
||||||
|
{ id: 'compare', icon: '🔀', label: '成本对比' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CostCockpit() {
|
||||||
|
const { theme, tokens, toggleTheme } = useTheme();
|
||||||
|
const { orders } = useOrders();
|
||||||
|
|
||||||
|
// 同步 body 背景色(CSS 变量)
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.style.setProperty('--body-bg', tokens.bodyBg);
|
||||||
|
}, [tokens.bodyBg]);
|
||||||
|
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
||||||
|
const [leftExpanded, setLeftExpanded] = useState(false);
|
||||||
|
const [rightExpanded, setRightExpanded] = useState(false);
|
||||||
|
const [centerFullscreen, setCenterFullscreen] = useState(false);
|
||||||
|
const [centerTab, setCenterTab] = useState('decompose');
|
||||||
|
const [droppedEquipment, setDroppedEquipment] = useState<Equipment | null>(null);
|
||||||
|
const [processingOrder, setProcessingOrder] = useState<Order | null>(null);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [workspaceResetKey] = useState(0);
|
||||||
|
const [bottomHidden, setBottomHidden] = useState(false);
|
||||||
|
const processingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const startProcessing = (order: Order) => {
|
||||||
|
if (processingTimer.current) clearTimeout(processingTimer.current);
|
||||||
|
setSelectedOrder(order);
|
||||||
|
setIsProcessing(true);
|
||||||
|
setProcessingOrder(order);
|
||||||
|
processingTimer.current = setTimeout(() => {
|
||||||
|
setIsProcessing(false);
|
||||||
|
setProcessingOrder(null);
|
||||||
|
}, 3500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCenterDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const orderData = e.dataTransfer.getData('application/x-order-json');
|
||||||
|
const orderEqData = e.dataTransfer.getData('application/x-equipment-json');
|
||||||
|
if (orderEqData) {
|
||||||
|
try {
|
||||||
|
const eq: Equipment = JSON.parse(orderEqData);
|
||||||
|
setDroppedEquipment(eq);
|
||||||
|
setCenterTab('profit');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (orderData) {
|
||||||
|
try {
|
||||||
|
const order: Order = JSON.parse(orderData);
|
||||||
|
startProcessing(order);
|
||||||
|
setCenterTab('decompose');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Esc 退出全屏
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && centerFullscreen) setCenterFullscreen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [centerFullscreen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100vw', height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: "-apple-system, 'SF Pro Text', 'PingFang SC', sans-serif",
|
||||||
|
color: tokens.text,
|
||||||
|
background: tokens.bodyBg,
|
||||||
|
}}>
|
||||||
|
{/* 星空背景 */}
|
||||||
|
<StarBackground theme={theme} />
|
||||||
|
|
||||||
|
{/* 内容层 */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
|
||||||
|
{/* 顶部标题栏 */}
|
||||||
|
<CostHeader theme={theme} onToggleTheme={toggleTheme} orders={orders} />
|
||||||
|
|
||||||
|
{/* 留白 */}
|
||||||
|
<div style={{ height: 14, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 中部三栏区域 */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', minHeight: 0, position: 'relative', gap: 0, padding: '0 0' }}>
|
||||||
|
|
||||||
|
{/* 左侧:订单列表面板 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', flexShrink: 0,
|
||||||
|
marginTop: 80, height: '100%',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
width: leftExpanded ? 474 : 282,
|
||||||
|
}}>
|
||||||
|
<LeftOrderPanel
|
||||||
|
selectedId={selectedOrder?.id ?? null}
|
||||||
|
onExpandChange={setLeftExpanded}
|
||||||
|
orders={orders}
|
||||||
|
onSelect={(order) => { setSelectedOrder(order); setCenterTab('decompose'); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 间隙 */}
|
||||||
|
<div style={{ width: 20, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 中央:智能成本分析 */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', position: 'relative', marginTop: 36 }}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, borderRadius: 16, overflow: 'hidden',
|
||||||
|
border: `1px solid ${tokens.border}`,
|
||||||
|
boxShadow: `0 0 30px ${tokens.accent}10, 0 0 60px ${tokens.accent}06`,
|
||||||
|
background: tokens.centerBg,
|
||||||
|
backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* 四角装饰 */}
|
||||||
|
{['top-left', 'top-right', 'bottom-left', 'bottom-right'].map(pos => {
|
||||||
|
const isTop = pos.includes('top');
|
||||||
|
const isLeft = pos.includes('left');
|
||||||
|
return (
|
||||||
|
<div key={pos} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: isTop ? 8 : undefined, bottom: !isTop ? 8 : undefined,
|
||||||
|
left: isLeft ? 8 : undefined, right: !isLeft ? 8 : undefined,
|
||||||
|
width: 16, height: 16, zIndex: 5, pointerEvents: 'none',
|
||||||
|
borderTop: isTop ? `2px solid ${tokens.accent}60` : 'none',
|
||||||
|
borderBottom: !isTop ? `2px solid ${tokens.accent}60` : 'none',
|
||||||
|
borderLeft: isLeft ? `2px solid ${tokens.accent}60` : 'none',
|
||||||
|
borderRight: !isLeft ? `2px solid ${tokens.accent}60` : 'none',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 中央标题栏 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px 10px', borderBottom: `1px solid ${tokens.border}`,
|
||||||
|
display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', gap: 10, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div />
|
||||||
|
<span style={{
|
||||||
|
color: tokens.text, fontSize: 21, fontWeight: 800,
|
||||||
|
letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
智能成本分析 Agentic Cost Analysis
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 8 }}>
|
||||||
|
{selectedOrder && (
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 8px', borderRadius: 6,
|
||||||
|
background: tokens.accentBg, border: `1px solid ${tokens.accent}25`,
|
||||||
|
color: tokens.accent, fontSize: 12,
|
||||||
|
}}>
|
||||||
|
{selectedOrder.orderNo}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setCenterFullscreen(f => !f)} style={{
|
||||||
|
padding: '3px 8px', borderRadius: 6,
|
||||||
|
border: `1px solid ${tokens.border}`,
|
||||||
|
background: 'transparent', color: tokens.textMuted,
|
||||||
|
fontSize: 12, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{centerFullscreen ? '退出全屏' : '全屏'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 功能导航栏 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'center', gap: 4,
|
||||||
|
padding: '8px 24px', borderBottom: `1px solid ${tokens.border}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{NAV_ITEMS.map(item => {
|
||||||
|
const active = centerTab === item.id;
|
||||||
|
return (
|
||||||
|
<button key={item.id} onClick={() => setCenterTab(item.id)} style={{
|
||||||
|
flex: 1, maxWidth: 200,
|
||||||
|
padding: '8px 12px', borderRadius: 8,
|
||||||
|
border: `1px solid ${active ? tokens.accent + '50' : 'transparent'}`,
|
||||||
|
background: active ? tokens.accentBg : 'transparent',
|
||||||
|
color: active ? tokens.accent : tokens.textMuted,
|
||||||
|
fontSize: 13, fontWeight: active ? 700 : 500,
|
||||||
|
cursor: 'pointer', transition: 'all 0.2s ease',
|
||||||
|
borderBottom: active ? `2px solid ${tokens.accent}` : '2px solid transparent',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中央内容区 */}
|
||||||
|
<div
|
||||||
|
onDragOver={e => e.preventDefault()}
|
||||||
|
onDrop={handleCenterDrop}
|
||||||
|
style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
{centerTab === 'decompose' && (
|
||||||
|
<CenterBOMView
|
||||||
|
key="bom"
|
||||||
|
order={selectedOrder}
|
||||||
|
onDrop={handleCenterDrop}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
processingOrder={processingOrder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{centerTab === 'theory' && (
|
||||||
|
<CenterTheoryCost
|
||||||
|
key="theory"
|
||||||
|
order={selectedOrder}
|
||||||
|
resetKey={workspaceResetKey}
|
||||||
|
onDrop={handleCenterDrop}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
processingOrder={processingOrder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{centerTab === 'profit' && (
|
||||||
|
<CenterProductionSim
|
||||||
|
key="profit"
|
||||||
|
order={selectedOrder}
|
||||||
|
equipment={droppedEquipment}
|
||||||
|
onEquipmentUsed={() => setDroppedEquipment(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{centerTab === 'compare' && (
|
||||||
|
<CenterCostCompare
|
||||||
|
key="compare"
|
||||||
|
order={selectedOrder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 间隙 */}
|
||||||
|
<div style={{ width: 20, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 右侧:生产设备库 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', flexShrink: 0,
|
||||||
|
marginTop: 80, height: '100%',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
width: rightExpanded ? 494 : 282,
|
||||||
|
}}>
|
||||||
|
<RightAIPanel
|
||||||
|
selectedOrder={selectedOrder}
|
||||||
|
onExpandChange={setRightExpanded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部AI问答区 — 根据左右侧板展开状态自动收缩避让,支持隐藏为浮标 */}
|
||||||
|
{bottomHidden ? (
|
||||||
|
<div style={{ flexShrink: 0, display: 'flex', justifyContent: 'center', paddingBottom: 12, position: 'relative', zIndex: 10 }}>
|
||||||
|
<button onClick={() => setBottomHidden(false)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 18px', borderRadius: 24,
|
||||||
|
border: `1px solid ${tokens.accent}50`,
|
||||||
|
background: tokens.bottomBg,
|
||||||
|
backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
color: tokens.accent, fontSize: 13, fontWeight: 700,
|
||||||
|
cursor: 'pointer', boxShadow: `0 4px 20px ${tokens.accent}20`,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
|
<circle cx="9" cy="9" r="7" stroke={tokens.accent} strokeWidth="1.5"/>
|
||||||
|
<path d="M 6,7 L 12,7 M 6,9 L 12,9 M 6,11 L 10,11" stroke={tokens.accent} strokeWidth="1.2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
AI 智能问答
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 底部留白 */}
|
||||||
|
<div style={{ height: 12, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
marginLeft: (leftExpanded ? 474 : 282) + 20,
|
||||||
|
marginRight: (rightExpanded ? 494 : 282) + 20,
|
||||||
|
transition: 'margin 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||||
|
}}>
|
||||||
|
<BottomCostBar selectedOrder={selectedOrder} onHide={() => setBottomHidden(true)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ThemeProvider } from '../context/ThemeContext';
|
||||||
|
import { DataSourceProvider } from '../context/DataSourceContext';
|
||||||
|
import AIMCockpit from '../pages-orig/AIMCockpit';
|
||||||
|
import '../styles.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AIMCockpitFull — order-cost-analysis 模块 AIM 子页面入口
|
||||||
|
*/
|
||||||
|
export default function AIMCockpitFull() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<DataSourceProvider>
|
||||||
|
<AIMCockpit />
|
||||||
|
</DataSourceProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ThemeProvider } from '../context/ThemeContext';
|
||||||
|
import { DataSourceProvider } from '../context/DataSourceContext';
|
||||||
|
import CostCockpit from '../pages-orig/CostCockpit';
|
||||||
|
import '../styles.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CostCockpitFull — order-cost-analysis 模块入口
|
||||||
|
* 在 ai-platform 顶层 BrowserRouter 之上,补充模块内 ThemeProvider(独立切换主题)
|
||||||
|
* 与 DataSourceProvider(静态/API 数据源切换 + 健康检查)
|
||||||
|
*/
|
||||||
|
export default function CostCockpitFull() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<DataSourceProvider>
|
||||||
|
<CostCockpit />
|
||||||
|
</DataSourceProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 字段适配层 (order-cost-analysis) — API snake_case → 前端 camelCase
|
||||||
|
*/
|
||||||
|
import type { Order, CostRow, CostBreakdown, CostSubItem, QuoteCostItem } from '../data/orders';
|
||||||
|
|
||||||
|
/** API order detail → 前端 Order */
|
||||||
|
export function adaptOrder(item: Record<string, unknown>): Order {
|
||||||
|
const costRows = (item.cost_rows as Record<string, unknown>[]) || [];
|
||||||
|
const breakdowns = (item.cost_breakdowns as Record<string, unknown>[]) || [];
|
||||||
|
const quoteItems = (item.quote_cost_items as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id as string,
|
||||||
|
orderNo: (item.order_no as string) || '',
|
||||||
|
customer: (item.customer as string) || '',
|
||||||
|
salesperson: (item.salesperson as string) || '',
|
||||||
|
deliveryPriority: (item.delivery_priority as Order['deliveryPriority']) || 'normal',
|
||||||
|
channel: (item.channel as Order['channel']) || '外销-B2B',
|
||||||
|
closedDate: (item.closed_date as string) || '',
|
||||||
|
productCategory: (item.product_category as string) || '',
|
||||||
|
status: (item.status as Order['status']) || 'open',
|
||||||
|
riskLevel: (item.risk_level as Order['riskLevel']) || 'normal',
|
||||||
|
region: (item.region as string) || '',
|
||||||
|
tags: (item.tags as string[]) || [],
|
||||||
|
submittedAt: (item.submitted_at as string) || '',
|
||||||
|
note: (item.note as string) || undefined,
|
||||||
|
rows: costRows.map(adaptCostRow),
|
||||||
|
costBreakdown: breakdowns.map(adaptBreakdown),
|
||||||
|
quoteCostItems: quoteItems.length > 0 ? quoteItems.map(adaptQuoteItem) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adaptCostRow(r: Record<string, unknown>): CostRow {
|
||||||
|
const revenue = (r.revenue as number) || 0;
|
||||||
|
const dm = (r.direct_material as number) || 0;
|
||||||
|
const dl = (r.direct_labor as number) || 0;
|
||||||
|
const mc = (r.manufacturing_cost as number) || 0;
|
||||||
|
// detail 从前端静态数据无法还原,API 也不返回,给默认拆分
|
||||||
|
const sp = [0.6, 0.4];
|
||||||
|
return {
|
||||||
|
type: (r.type as CostRow['type']) || '原材料成本',
|
||||||
|
qty: (r.qty as number) || 0,
|
||||||
|
unitPrice: (r.unit_price as number) || 0,
|
||||||
|
revenue,
|
||||||
|
directMaterial: dm,
|
||||||
|
directLabor: dl,
|
||||||
|
manufacturingCost: mc,
|
||||||
|
totalCost: (r.total_cost as number) || 0,
|
||||||
|
grossProfit: (r.gross_profit as number) || 0,
|
||||||
|
grossMargin: (r.gross_margin as number) || 0,
|
||||||
|
mgmtExpense: (r.mgmt_expense as number) || 0,
|
||||||
|
netProfit: (r.net_profit as number) || 0,
|
||||||
|
netMargin: (r.net_margin as number) || 0,
|
||||||
|
detail: {
|
||||||
|
fabric: Math.round(dm * sp[0]),
|
||||||
|
accessories: Math.round(dm * sp[1]),
|
||||||
|
labor: dl,
|
||||||
|
overhead: mc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adaptBreakdown(b: Record<string, unknown>): CostBreakdown {
|
||||||
|
const subItems = (b.sub_items as Record<string, unknown>[]) || [];
|
||||||
|
return {
|
||||||
|
category: (b.category as string) || '',
|
||||||
|
items: subItems.map(adaptSubItem),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adaptSubItem(s: Record<string, unknown>): CostSubItem {
|
||||||
|
return {
|
||||||
|
label: (s.label as string) || '',
|
||||||
|
stdCost: (s.std_cost as number) || 0,
|
||||||
|
stdPct: 0,
|
||||||
|
purchaseCost: (s.purchase_cost as number) || 0,
|
||||||
|
purchasePct: 0,
|
||||||
|
usageCost: (s.usage_cost as number) || 0,
|
||||||
|
usagePct: 0,
|
||||||
|
purchaseVsStdAmt: 0,
|
||||||
|
purchaseVsStdPct: 0,
|
||||||
|
usageVsStdAmt: 0,
|
||||||
|
usageVsStdPct: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adaptQuoteItem(q: Record<string, unknown>): QuoteCostItem {
|
||||||
|
return {
|
||||||
|
name: (q.name as string) || '',
|
||||||
|
quotedPrice: (q.quoted_price as number) || 0,
|
||||||
|
quantity: (q.quantity as number) || 0,
|
||||||
|
totalCost: (q.total_cost as number) || 0,
|
||||||
|
percentage: (q.percentage as number) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user