feat: init ai-platform - 源码 + 配置

This commit is contained in:
2026-08-25 16:27:43 +08:00
commit 83210a606a
122 changed files with 27401 additions and 0 deletions
+44
View File
@@ -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
View File
@@ -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>
+3511
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+16
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+267
View File
@@ -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
+14
View File
@@ -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">&#128230;</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' }}>&#8594;</span>}
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{b.code}</span>
</span>
))}
<span style={{ color: 'var(--text-3)', margin: '0 2px' }}>&#8594;</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">&#128269;</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">&#128237;</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();
}
+14
View File
@@ -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 }}>
单位:元 &nbsp;币种:人民币
</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>单位:元 &nbsp;币种:人民币</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;
}
+912
View File
@@ -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' },
};
+14
View File
@@ -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': '标准件',
};
+14
View File
@@ -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 }}>
单位:元 &nbsp;币种:人民币
</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)' },
};
+14
View File
@@ -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