From 51e44d1eaf6e88437dcc5bb50f49c1b44c3d23a2 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 3 Aug 2026 12:39:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A6=96=E9=A1=B5=E9=97=A8=E6=88=B7?= =?UTF-8?q?=E5=8C=96=20+=20=E4=BE=9B=E5=BA=94=E9=93=BE=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首页改门户选择页: - HomeClient.workspace 加门户短路,渲染 HomePortal(两个工作台入口) - HomePortal:产品开发(→/intake) + 供应链中心(→/supply-chain) 两大入口卡 - 顶部导航:首页时只显示「首页」,进入产品开发后恢复全部功能导航 - 入口图标:产品开发用 Package,供应链用 Truck - 时间问候语(早午晚好+用户名) 供应链中心(src/app/supply-chain/): - 独立布局:窄边栏(76px) + hover二级菜单浮层 + 多标签页 + 顶栏搜索 - 复用主项目顶栏(保留首页导航),供应链路由时品牌切换为「由壹点「供应链中心」」 - 全局菜单搜索:模糊匹配 + 拼音全拼/首字母 + 键盘导航(↑↓Enter Esc) - 4个页面:工作台(问候语)、产品全量档案、日常事项(空壳)、产品变价(空壳) - 产品全量档案:多级表头(5分组81字段) + 列设置抽屉(勾选显隐+拖拽排序+重置) + localStorage持久化 + 浮动分页 视觉:白曜石规范(浅色精密、克制通透、石墨灰强调色) 基于 origin/main,不含其他线程改动 --- .../src/app/HomeClient.workspace.tsx | 4 + .../src/app/home/components/HomePortal.tsx | 93 ++++++++ .../app/supply-chain/SupplyChainClient.tsx | 29 +++ .../components/SupplyChainLayout.tsx | 60 +++++ .../components/SupplyChainSearch.tsx | 152 +++++++++++++ .../components/SupplyChainSidebar.tsx | 215 ++++++++++++++++++ .../components/SupplyChainTabBar.tsx | 110 +++++++++ .../supply-chain/components/search-menu.ts | 49 ++++ .../components/supply-chain-tabs.tsx | 88 +++++++ .../supply-chain/config/supply-chain-menu.ts | 73 ++++++ .../supply-chain/dashboard/DashboardView.tsx | 69 ++++++ .../src/app/supply-chain/dashboard/page.tsx | 9 + .../product-archive/ColumnSettingsPanel.tsx | 202 ++++++++++++++++ .../product-archive/ProductArchiveView.tsx | 215 ++++++++++++++++++ .../database/product-archive/columns.ts | 132 +++++++++++ .../database/product-archive/page.tsx | 9 + .../product-archive/useColumnSettings.ts | 113 +++++++++ .../src/app/supply-chain/layout.tsx | 8 + .../src/app/supply-chain/page.tsx | 9 + .../components/PurchasePlaceholder.tsx | 50 ++++ .../purchase/daily-affairs/page.tsx | 9 + .../purchase/price-change/page.tsx | 9 + .../src/components/layout/AppShell.tsx | 6 +- .../src/components/layout/AppTopNav.tsx | 11 +- 24 files changed, 1721 insertions(+), 3 deletions(-) create mode 100644 furniture-3d-system/src/app/home/components/HomePortal.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/SupplyChainClient.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/components/SupplyChainLayout.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/components/SupplyChainSearch.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/components/SupplyChainTabBar.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/components/search-menu.ts create mode 100644 furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts create mode 100644 furniture-3d-system/src/app/supply-chain/dashboard/DashboardView.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/dashboard/page.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/database/product-archive/ColumnSettingsPanel.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveView.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts create mode 100644 furniture-3d-system/src/app/supply-chain/database/product-archive/page.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.ts create mode 100644 furniture-3d-system/src/app/supply-chain/layout.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/page.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/components/PurchasePlaceholder.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/daily-affairs/page.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/purchase/price-change/page.tsx diff --git a/furniture-3d-system/src/app/HomeClient.workspace.tsx b/furniture-3d-system/src/app/HomeClient.workspace.tsx index ed2b0e2b..d2d30be6 100644 --- a/furniture-3d-system/src/app/HomeClient.workspace.tsx +++ b/furniture-3d-system/src/app/HomeClient.workspace.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { useCallback, useDeferredValue, useEffect, useRef, useState } from 'react' import { useBreakpoint } from '@/hooks/useMediaQuery' import HomeMobileView from './mobile/HomeMobileView' +import { HomePortal } from './home/components/HomePortal' import { ArrowDownToLine, ArrowRight, @@ -418,6 +419,9 @@ export default function HomeClient({ }, ] + // ===== 首页门户:只提供两个工作台入口,桌面/移动通用 ===== + return + // ===== Stitch 移动端布局 ===== if (isMobileOrTablet) { return ( diff --git a/furniture-3d-system/src/app/home/components/HomePortal.tsx b/furniture-3d-system/src/app/home/components/HomePortal.tsx new file mode 100644 index 00000000..0a74cf2f --- /dev/null +++ b/furniture-3d-system/src/app/home/components/HomePortal.tsx @@ -0,0 +1,93 @@ +'use client' + +import Link from 'next/link' +import { ArrowRight, Package, Truck } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +type HomePortalUser = { + name?: string | null + email?: string | null +} + +function getGreeting(): string { + const hour = new Date().getHours() + if (hour < 6) return '夜深了' + if (hour < 9) return '早上好' + if (hour < 12) return '上午好' + if (hour < 14) return '中午好' + if (hour < 18) return '下午好' + return '晚上好' +} + +type PortalEntry = { + href: string + icon: LucideIcon + title: string + description: string +} + +const PORTAL_ENTRIES: PortalEntry[] = [ + { + href: '/intake', + icon: Package, + title: '由壹点产品开发', + description: '资产中心 · 辅助装配 · 草稿组 · 产品资料 · 仓库管理', + }, + { + href: '/supply-chain', + icon: Truck, + title: '由壹点供应链中心', + description: '供应商 · 采购 · 供应链协作(建设中)', + }, +] + +export function HomePortal({ user }: { user: HomePortalUser }) { + return ( +
+
+
+ +
+
+

+ WORKSPACE +

+

+ {getGreeting()},{user?.name || '你好'} +

+

+ 选择要进入的工作台 +

+
+ +
+ {PORTAL_ENTRIES.map((entry) => { + const Icon = entry.icon + return ( + +
+ + + + +
+
+

+ {entry.title} +

+

+ {entry.description} +

+
+ + ) + })} +
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/SupplyChainClient.tsx b/furniture-3d-system/src/app/supply-chain/SupplyChainClient.tsx new file mode 100644 index 00000000..6ba0e3d3 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/SupplyChainClient.tsx @@ -0,0 +1,29 @@ +'use client' + +import Link from 'next/link' +import { ArrowLeft, Boxes } from 'lucide-react' + +export function SupplyChainClient() { + return ( +
+
+
+ +
+

+ 由壹点供应链中心 +

+

+ 供应链中心正在建设中,内容将随后续配置逐步开放。 +

+ + + 返回首页 + +
+
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/components/SupplyChainLayout.tsx b/furniture-3d-system/src/app/supply-chain/components/SupplyChainLayout.tsx new file mode 100644 index 00000000..f17411a3 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/components/SupplyChainLayout.tsx @@ -0,0 +1,60 @@ +'use client' + +import { useEffect, type ReactNode } from 'react' +import { usePathname } from 'next/navigation' +import { SupplyChainSidebar } from './SupplyChainSidebar' +import { SupplyChainTabBar } from './SupplyChainTabBar' +import { SupplyChainTabsProvider, useSupplyChainTabs, SUPPLY_CHAIN_ROUTE_TITLES } from './supply-chain-tabs' + +// 自动根据路由建标签 +function RouteTabSync({ children }: { children: ReactNode }) { + const pathname = usePathname() ?? '' + const { addTab } = useSupplyChainTabs() + useEffect(() => { + const title = SUPPLY_CHAIN_ROUTE_TITLES[pathname] + if (title) { + addTab(pathname, title) + } + }, [pathname, addTab]) + return <>{children} +} + +export function SupplyChainLayout({ children }: { children: ReactNode }) { + return ( + + +
+ +
+ +
{children}
+
+
+
+ +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/components/SupplyChainSearch.tsx b/furniture-3d-system/src/app/supply-chain/components/SupplyChainSearch.tsx new file mode 100644 index 00000000..9af16eb2 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/components/SupplyChainSearch.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { Search } from 'lucide-react' +import { searchSupplyChainMenu } from './search-menu' + +// 图标名映射(和侧边栏一致) +import { LayoutDashboard, Database, ShoppingCart, type LucideIcon } from 'lucide-react' +const ICON_MAP: Record = { LayoutDashboard, Database, ShoppingCart } + +export function SupplyChainSearch() { + const router = useRouter() + const [keyword, setKeyword] = useState('') + const [open, setOpen] = useState(false) + const [hoverIdx, setHoverIdx] = useState(0) + const inputRef = useRef(null) + const wrapRef = useRef(null) + + const results = keyword.trim() ? searchSupplyChainMenu(keyword) : [] + + useEffect(() => { setHoverIdx(0) }, [keyword]) + + // 点击外部关闭 + useEffect(() => { + const handler = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, []) + + const jump = (route: string) => { + router.push(route) + setKeyword('') + setOpen(false) + inputRef.current?.blur() + } + + const onKeyDown = (e: React.KeyboardEvent) => { + if (!open || results.length === 0) return + if (e.key === 'ArrowDown') { e.preventDefault(); setHoverIdx((i) => Math.min(i + 1, results.length - 1)) } + else if (e.key === 'ArrowUp') { e.preventDefault(); setHoverIdx((i) => Math.max(i - 1, 0)) } + else if (e.key === 'Enter') { e.preventDefault(); if (results[hoverIdx]) jump(results[hoverIdx].route) } + else if (e.key === 'Escape') { setKeyword(''); setOpen(false) } + } + + return ( +
+ + { setKeyword(e.target.value); setOpen(true) }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + /> + {open && results.length > 0 && ( +
+ {results.map((r, i) => { + const Icon = ICON_MAP[r.icon] ?? LayoutDashboard + return ( +
setHoverIdx(i)} + onClick={() => jump(r.route)} + > + +
+ {r.label} + {r.path} +
+
+ ) + })} +
+ )} + +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.tsx b/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.tsx new file mode 100644 index 00000000..3d195f1a --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/components/SupplyChainSidebar.tsx @@ -0,0 +1,215 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { + LayoutDashboard, + Database, + ShoppingCart, + type LucideIcon, +} from 'lucide-react' +import { SUPPLY_CHAIN_MENU, type SupplyChainMenuModule } from '../config/supply-chain-menu' + +const ICON_MAP: Record = { + LayoutDashboard, + Database, + ShoppingCart, +} + +// 当前路由是否命中该模块 +function isModuleActive(pathname: string, mod: SupplyChainMenuModule): boolean { + if (mod.route && (pathname === mod.route || pathname.startsWith(mod.route + '/'))) return true + if (mod.sections) { + for (const section of mod.sections) { + for (const item of section.items) { + if (pathname === item.route || pathname.startsWith(item.route + '/')) return true + } + } + } + return false +} + +function isItemActive(pathname: string, route: string): boolean { + return pathname === route || pathname.startsWith(route + '/') +} + +export function SupplyChainSidebar() { + const pathname = usePathname() ?? '' + + return ( + + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/components/SupplyChainTabBar.tsx b/furniture-3d-system/src/app/supply-chain/components/SupplyChainTabBar.tsx new file mode 100644 index 00000000..54de175e --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/components/SupplyChainTabBar.tsx @@ -0,0 +1,110 @@ +'use client' + +import { X } from 'lucide-react' +import { useSupplyChainTabs } from './supply-chain-tabs' + +export function SupplyChainTabBar() { + const { tabs, activePath, clickTab, closeTab } = useSupplyChainTabs() + + return ( +
+
+ {tabs.map((tab) => { + const active = tab.path === activePath + return ( +
clickTab(tab.path)} + > + {tab.title} + {tab.closable && ( + + )} +
+ ) + })} +
+ +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/components/search-menu.ts b/furniture-3d-system/src/app/supply-chain/components/search-menu.ts new file mode 100644 index 00000000..88396a18 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/components/search-menu.ts @@ -0,0 +1,49 @@ +// 供应链中心菜单搜索算法(对应 caigou searchMenu.js) +// 匹配:文字包含(5分) + 拼音包含(4分) + 拼音首字母包含(3分) +import { pinyin } from 'pinyin-pro' +import { flattenSupplyChainMenu } from '../config/supply-chain-menu' + +type FlatItem = ReturnType[number] + +function toPinyin(text: string): string { + // 去声调,小写,无空格 + return pinyin(text, { toneType: 'none', type: 'array' }).join('').toLowerCase() +} + +function toPinyinInitials(text: string): string { + return pinyin(text, { toneType: 'none', type: 'array' }) + .map((s: string) => s[0] ?? '') + .join('') + .toLowerCase() +} + +function matchItem(item: FlatItem, keyword: string): number { + const kw = keyword.trim().toLowerCase() + if (!kw) return 0 + const label = item.label.toLowerCase() + + // 1. 文字包含(5分) + if (label.includes(kw)) return 5 + + const py = toPinyin(item.label) + const initials = toPinyinInitials(item.label) + + // 2. 拼音全拼包含(4分) + if (py.includes(kw)) return 4 + + // 3. 拼音首字母包含(3分) + if (initials.includes(kw)) return 3 + + return 0 +} + +export function searchSupplyChainMenu(keyword: string): Array { + const items = flattenSupplyChainMenu() + const kw = keyword.trim() + if (!kw) return [] + + return items + .map((item) => ({ ...item, score: matchItem(item, kw) })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score) +} diff --git a/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx b/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx new file mode 100644 index 00000000..8dc01400 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx @@ -0,0 +1,88 @@ +'use client' + +import { createContext, useContext, useState, useCallback, type ReactNode } from 'react' +import { usePathname, useRouter } from 'next/navigation' + +// 供应链中心多标签页状态(对应 caigou useTags) +// 工作台固定标签不可关闭,其余可关闭 + +export type SupplyChainTab = { + path: string + title: string + closable: boolean +} + +type TabsContextValue = { + tabs: SupplyChainTab[] + activePath: string + addTab: (path: string, title: string) => void + closeTab: (path: string) => void + clickTab: (path: string) => void +} + +const TabsContext = createContext(null) + +const HOME_TAB: SupplyChainTab = { + path: '/supply-chain/dashboard', + title: '工作台', + closable: false, +} + +export function SupplyChainTabsProvider({ children }: { children: ReactNode }) { + const router = useRouter() + const pathname = usePathname() ?? '/supply-chain/dashboard' + const [tabs, setTabs] = useState([HOME_TAB]) + + // 根据 pathname 补标签 + const ensureTab = useCallback((path: string, title: string) => { + setTabs((current) => { + // 工作台不重复加 + if (path === HOME_TAB.path) return current + if (current.some((t) => t.path === path)) return current + return [...current, { path, title, closable: true }] + }) + }, []) + + const addTab = useCallback((path: string, title: string) => { + ensureTab(path, title) + router.push(path) + }, [ensureTab, router]) + + const closeTab = useCallback((path: string) => { + setTabs((current) => { + const idx = current.findIndex((t) => t.path === path) + if (idx === -1 || !current[idx].closable) return current + const next = current.filter((t) => t.path !== path) + // 关的是当前激活的,切到相邻 + if (pathname === path) { + const target = next[idx] ?? next[idx - 1] ?? next[0] + if (target) router.push(target.path) + } + return next + }) + }, [pathname, router]) + + const clickTab = useCallback((path: string) => { + router.push(path) + }, [router]) + + return ( + + {children} + + ) +} + +export function useSupplyChainTabs() { + const ctx = useContext(TabsContext) + if (!ctx) throw new Error('useSupplyChainTabs 必须在 SupplyChainTabsProvider 内使用') + return ctx +} + +// 路径到标题的映射(用于自动建标签) +export const SUPPLY_CHAIN_ROUTE_TITLES: Record = { + '/supply-chain/dashboard': '工作台', + '/supply-chain/database/product-archive': '产品全量档案', + '/supply-chain/purchase/daily-affairs': '日常事项', + '/supply-chain/purchase/price-change': '产品变价', +} diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts new file mode 100644 index 00000000..d02ffe5a --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts @@ -0,0 +1,73 @@ +// 供应链中心侧边栏菜单配置(唯一数据源) +// 对应 caigou 的 src/config/menu.js +// 新增菜单项只需在此添加,侧边栏和搜索会自动收录 + +export type SupplyChainMenuItem = { + label: string + route: string +} + +export type SupplyChainMenuSection = { + title?: string + items: SupplyChainMenuItem[] +} + +export type SupplyChainMenuModule = { + key: string + label: string + icon: string // lucide 图标名 + route?: string // 叶子页(如工作台) + sections?: SupplyChainMenuSection[] // 二级菜单 +} + +export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [ + { + key: 'dashboard', + label: '工作台', + icon: 'LayoutDashboard', + route: '/supply-chain/dashboard', + }, + { + key: 'database', + label: '数据库', + icon: 'Database', + sections: [ + { + items: [ + { label: '产品全量档案', route: '/supply-chain/database/product-archive' }, + ], + }, + ], + }, + { + key: 'purchase', + label: '采购管理', + icon: 'ShoppingCart', + sections: [ + { + items: [ + { label: '日常事项', route: '/supply-chain/purchase/daily-affairs' }, + { label: '产品变价', route: '/supply-chain/purchase/price-change' }, + ], + }, + ], + }, +] + +// 拍平菜单(供搜索用) +export function flattenSupplyChainMenu() { + const result: Array<{ label: string; module: string; route: string; icon: string; path: string }> = [] + for (const mod of SUPPLY_CHAIN_MENU) { + if (mod.route) { + result.push({ label: mod.label, module: mod.label, route: mod.route, icon: mod.icon, path: mod.label }) + } + if (mod.sections) { + for (const section of mod.sections) { + for (const item of section.items) { + result.push({ label: item.label, module: mod.label, route: item.route, icon: mod.icon, path: `${mod.label} / ${item.label}` }) + } + } + } + } + return result +} diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/DashboardView.tsx b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardView.tsx new file mode 100644 index 00000000..42140719 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/dashboard/DashboardView.tsx @@ -0,0 +1,69 @@ +'use client' + +import { Calendar } from 'lucide-react' + +function getGreeting(): string { + const hour = new Date().getHours() + if (hour < 6) return '凌晨好' + if (hour < 9) return '早上好' + if (hour < 12) return '上午好' + if (hour < 14) return '中午好' + if (hour < 18) return '下午好' + return '晚上好' +} + +function getToday(): string { + const d = new Date() + const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()] + return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${week}` +} + +export function DashboardView({ username }: { username?: string | null }) { + return ( +
+
+

{getGreeting()},{username || '你好'}

+

欢迎回到供应链中心

+
+ + {getToday()} +
+
+ +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/dashboard/page.tsx b/furniture-3d-system/src/app/supply-chain/dashboard/page.tsx new file mode 100644 index 00000000..064922cd --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/dashboard/page.tsx @@ -0,0 +1,9 @@ +import { requireSession } from '@/lib/server-session' +import { DashboardView } from './DashboardView' + +export const metadata = { title: '工作台 · 供应链中心' } + +export default async function DashboardPage() { + const { authUser } = await requireSession('/login') + return +} diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/ColumnSettingsPanel.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/ColumnSettingsPanel.tsx new file mode 100644 index 00000000..e4b81fc1 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/ColumnSettingsPanel.tsx @@ -0,0 +1,202 @@ +'use client' + +import { useState } from 'react' +import { GripVertical, RotateCcw, X, Check } from 'lucide-react' +import { useColumnSettings, type ColumnSetting } from './useColumnSettings' + +export function ColumnSettingsPanel({ open, onClose }: { open: boolean; onClose: () => void }) { + const { settings, toggleColumn, toggleGroup, setGroupAll, reset, setSettings } = useColumnSettings() + const [msg, setMsg] = useState('') + + const onReset = () => { + reset() + setMsg('已重置为默认列设置') + setTimeout(() => setMsg(''), 2000) + } + + const isGroupAll = (g: ColumnSetting) => g.columns.every((c) => c.visible) + + // 原生拖拽:分组排序 + const [dragType, setDragType] = useState<{ kind: 'group' | 'col'; group?: string; index: number } | null>(null) + + const onDragStartGroup = (index: number) => setDragType({ kind: 'group', index }) + const onDropGroup = (targetIndex: number) => { + if (!dragType || dragType.kind !== 'group' || dragType.index === targetIndex) { setDragType(null); return } + setSettings((cur) => { + const next = [...cur] + const [moved] = next.splice(dragType.index, 1) + next.splice(targetIndex, 0, moved) + return next + }) + setDragType(null) + } + + const onDragStartCol = (group: string, index: number) => setDragType({ kind: 'col', group, index }) + const onDropCol = (group: string, targetIndex: number) => { + if (!dragType || dragType.kind !== 'col' || dragType.group !== group || dragType.index === targetIndex) { setDragType(null); return } + setSettings((cur) => + cur.map((g) => { + if (g.group !== group) return g + const cols = [...g.columns] + const [moved] = cols.splice(dragType.index, 1) + cols.splice(targetIndex, 0, moved) + return { ...g, columns: cols } + }), + ) + setDragType(null) + } + + if (!open) return null + + return ( + <> + {/* 遮罩 */} +
+ {/* 抽屉 */} +
+
+
+
列设置
+
勾选显示,拖拽排序
+
+ +
+ +
+ +
+ +
+ {settings.map((g, gi) => ( +
+
onDragStartGroup(gi)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => onDropGroup(gi)} + > + + + {g.columns.filter((c) => c.visible).length}/{g.columns.length} + +
+ + {g.groupVisible && ( +
+ {g.columns.map((c, ci) => ( +
onDragStartCol(g.group, ci)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => onDropCol(g.group, ci)} + > + + +
+ ))} +
+ )} +
+ ))} +
+ + {msg &&
{msg}
} +
+ + + + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveView.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveView.tsx new file mode 100644 index 00000000..23bba4aa --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveView.tsx @@ -0,0 +1,215 @@ +'use client' + +import { useState } from 'react' +import { Settings, ArrowLeft, ArrowRight } from 'lucide-react' +import { ARCHIVE_COLUMNS, GROUP_COLORS } from './columns' +import { useColumnSettings } from './useColumnSettings' +import { ColumnSettingsPanel } from './ColumnSettingsPanel' + +export function ProductArchiveView() { + const { effectiveColumns } = useColumnSettings() + const [showColumnSettings, setShowColumnSettings] = useState(false) + + // 分页(空数据,纯 UI) + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(50) + const total = 0 + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + + const visibleFieldCount = effectiveColumns.reduce((n, g) => n + g.columns.length, 0) + const totalFieldCount = ARCHIVE_COLUMNS.reduce((n, g) => n + g.columns.length, 0) + + return ( +
+ {/* 工具条 */} +
+ 产品全量档案 + + 显示 {visibleFieldCount} / {totalFieldCount} 个字段 · {effectiveColumns.length} 个分组 + + +
+ + {/* 表格 */} +
+ + + {/* 一级表头:分组 */} + + + {effectiveColumns.map((g) => ( + + ))} + + {/* 二级表头:字段 */} + + {effectiveColumns.map((g) => + g.columns.map((c) => ( + + )), + )} + + + {/* 空数据,后续接入 */} +
序号 + {g.group} +
+ {c.label} +
+ {total === 0 &&
暂无数据
} +
+ + {/* 分页(浮动胶囊) */} +
+ 共 {total} 条 + + + {currentPage} + / + {totalPages} 页 + + + + 每页 + + 条 + +
+ + setShowColumnSettings(false)} /> + + +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts new file mode 100644 index 00000000..13a82d6b --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts @@ -0,0 +1,132 @@ +// 产品全量档案 —— 表格列配置(多级表头) +// 对应 caigou src/views/database/product-archive/columns.js +// 结构:分组(一级表头)→ 字段(二级表头) + +export type ArchiveColumn = { + prop: string + label: string + width: number +} + +export type ArchiveColumnGroup = { + group: string + columns: ArchiveColumn[] +} + +export const ARCHIVE_COLUMNS: ArchiveColumnGroup[] = [ + { + group: '商品信息', + columns: [ + { prop: 'image', label: '图片', width: 80 }, + { prop: 'createTime', label: '创建时间', width: 150 }, + { prop: 'createDays', label: '创建天数', width: 90 }, + { prop: 'updateTime', label: '更新时间', width: 150 }, + { prop: 'categoryL1', label: '一级类目', width: 110 }, + { prop: 'categoryL2', label: '二级类目', width: 110 }, + { prop: 'spu', label: 'SPU', width: 120 }, + { prop: 'sku', label: 'SKU', width: 120 }, + { prop: 'barcode', label: '条码', width: 130 }, + { prop: 'productName', label: '商品名称', width: 180 }, + { prop: 'designer', label: '设计师', width: 100 }, + { prop: 'productStatus', label: '商品状态', width: 100 }, + { prop: 'material', label: '材质', width: 100 }, + { prop: 'color', label: '颜色', width: 90 }, + { prop: 'weight', label: '重量', width: 80 }, + { prop: 'length', label: '长', width: 70 }, + { prop: 'width', label: '宽', width: 70 }, + { prop: 'height', label: '高', width: 70 }, + { prop: 'volume', label: '体积', width: 80 }, + { prop: 'packType', label: '单双包', width: 80 }, + { prop: 'productTag', label: '商品标签', width: 110 }, + { prop: 'installDocId', label: '安装图纸/视频ID', width: 150 }, + { prop: 'spec', label: '规格', width: 100 }, + { prop: 'brand', label: '品牌', width: 100 }, + { prop: 'orderType', label: '叫货类型', width: 100 }, + { prop: 'storageType', label: '入库/代发', width: 100 }, + { prop: 'isSeasonal', label: '是否季节性产品', width: 130 }, + { prop: 'replaceCount', label: '替换款数量', width: 100 }, + ], + }, + { + group: '供应链信息', + columns: [ + { prop: 'region', label: '区域', width: 90 }, + { prop: 'supplier', label: '供应商', width: 150 }, + { prop: 'supplierStatus', label: '供应商状态', width: 110 }, + { prop: 'purchaser', label: '采购', width: 90 }, + { prop: 'settleType', label: '结算方式', width: 100 }, + { prop: 'boardProvider', label: '板材提供方', width: 110 }, + { prop: 'penaltyStatus', label: '违约金赔付状态', width: 130 }, + { prop: 'moq', label: '产品起订量', width: 100 }, + { prop: 'firstLeadTime', label: '首单生产周期', width: 120 }, + { prop: 'stableLeadTime', label: '稳定生产周期', width: 120 }, + { prop: 'palletSpec', label: '托盘参数', width: 100 }, + { prop: 'backupSupplier', label: '替换供应商', width: 120 }, + ], + }, + { + group: '运营信息', + columns: [ + { prop: 'opsDirector', label: '运营总负责人', width: 110 }, + { prop: 'opsDeptHead', label: '运营部门负责人', width: 130 }, + { prop: 'opsHead', label: '运营负责人', width: 110 }, + { prop: 'opsPriceLimit', label: '运营负责人限价', width: 130 }, + { prop: 'distributionIds', label: '铺货ID数量和明细', width: 150 }, + ], + }, + { + group: '仓库信息', + columns: [ + { prop: 'warehouseType', label: '仓库属性', width: 100 }, + { prop: 'inOutWarehouse', label: '出入库仓库', width: 110 }, + { prop: 'warehouseHead', label: '仓库负责人', width: 110 }, + ], + }, + { + group: '数据信息', + columns: [ + { prop: 'purchasePrice', label: '采购价', width: 90 }, + { prop: 'costPrice', label: '成本价', width: 90 }, + { prop: 'shipAmount', label: '发货金额', width: 100 }, + { prop: 'returnAmount', label: '退货金额', width: 100 }, + { prop: 'realPerformance', label: '真实业绩', width: 100 }, + { prop: 'shipQty', label: '发货件数', width: 90 }, + { prop: 'grossProfit', label: '毛利额', width: 90 }, + { prop: 'grossMargin', label: '毛利率', width: 80 }, + { prop: 'feeRatio', label: '费比', width: 70 }, + { prop: 'operatingProfit', label: '营业利润额', width: 100 }, + { prop: 'dataTag', label: '产品标签', width: 100 }, + { prop: 'dailySales', label: '采购日销', width: 90 }, + { prop: 'sales3d', label: '3日日销', width: 90 }, + { prop: 'sales7d', label: '7日日销', width: 90 }, + { prop: 'sales15d', label: '15日日销', width: 90 }, + { prop: 'sales30dWithBig', label: '含大单30日日销', width: 140 }, + { prop: 'stockDays', label: '库存可使用天数', width: 130 }, + { prop: 'stockJianshang', label: '简尚库存', width: 90 }, + { prop: 'stockClearance', label: '清货库存', width: 90 }, + { prop: 'stockFactory', label: '工厂库存', width: 90 }, + { prop: 'stockCloud', label: '云仓库存', width: 90 }, + { prop: 'inTransitQty', label: '在途数量', width: 90 }, + { prop: 'stockValue', label: '库存货值', width: 90 }, + { prop: 'slowStock', label: '滞销库存', width: 90 }, + { prop: 'slowStockValue', label: '滞销货值', width: 90 }, + { prop: 'afterSalesCount', label: '售后次数', width: 90 }, + { prop: 'afterSalesRate', label: '售后率', width: 80 }, + { prop: 'afterSalesCost', label: '售后成本', width: 90 }, + { prop: 'afterSalesCostRate', label: '售后成本率', width: 100 }, + { prop: 'returnQty', label: '退货件数', width: 90 }, + { prop: 'returnRate', label: '退货率', width: 80 }, + { prop: 'partsResendCount', label: '配件补发次数', width: 110 }, + { prop: 'partsRate', label: '配件率', width: 80 }, + ], + }, +] + +// 分组表头着色(白曜石:克制浅色区分分组) +export const GROUP_COLORS: Record = { + 商品信息: 'rgba(59, 130, 246, 0.08)', // 浅蓝 + 供应链信息: 'rgba(34, 197, 94, 0.08)', // 浅绿 + 运营信息: 'rgba(234, 179, 8, 0.08)', // 浅黄 + 仓库信息: 'rgba(168, 85, 247, 0.08)', // 浅紫 + 数据信息: 'rgba(244, 114, 182, 0.08)', // 浅粉 +} diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/page.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/page.tsx new file mode 100644 index 00000000..81c42a9c --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/page.tsx @@ -0,0 +1,9 @@ +import { requireSession } from '@/lib/server-session' +import { ProductArchiveView } from './ProductArchiveView' + +export const metadata = { title: '产品全量档案 · 供应链中心' } + +export default async function ProductArchivePage() { + await requireSession('/login') + return +} diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.ts new file mode 100644 index 00000000..aafdf57e --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/useColumnSettings.ts @@ -0,0 +1,113 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { ARCHIVE_COLUMNS, type ArchiveColumnGroup } from './columns' + +// 列设置状态:管理分组/字段的显示隐藏 + 顺序,localStorage 持久化 +// 对应 caigou useColumnSettings.js + +export type ColumnSetting = { + group: string + groupVisible: boolean + columns: { prop: string; label: string; width: number; visible: boolean }[] +} + +const STORAGE_KEY = 'caigou_archive_columns' + +// 从默认配置生成初始设置 +function buildDefault(): ColumnSetting[] { + return ARCHIVE_COLUMNS.map((g) => ({ + group: g.group, + groupVisible: true, + columns: g.columns.map((c) => ({ ...c, visible: true })), + })) +} + +// 从 localStorage 加载(与默认配置合并:保留新增字段、删多余字段) +function loadSettings(): ColumnSetting[] { + if (typeof window === 'undefined') return buildDefault() + const defaults = buildDefault() + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return defaults + const saved = JSON.parse(raw) as ColumnSetting[] + if (!Array.isArray(saved)) return defaults + // 合并:以 defaults 为准,用 saved 的 visible/顺序覆盖 + return defaults.map((dg) => { + const sg = saved.find((s) => s.group === dg.group) + if (!sg) return dg + // 字段顺序按 saved,但字段定义以 defaults 为准(处理增删) + const orderedCols = sg.columns + .map((sc) => { + const dc = dg.columns.find((d) => d.prop === sc.prop) + return dc ? { ...dc, visible: sc.visible } : null + }) + .filter(Boolean) as ColumnSetting['columns'] + // 补上 saved 里没有的新字段 + for (const dc of dg.columns) { + if (!orderedCols.some((c) => c.prop === dc.prop)) { + orderedCols.push(dc) + } + } + return { group: dg.group, groupVisible: sg.groupVisible, columns: orderedCols } + }) + } catch { + return defaults + } +} + +export function useColumnSettings() { + const [settings, setSettings] = useState(buildDefault) + + // 客户端首次加载后读取 localStorage + useEffect(() => { + setSettings(loadSettings()) + }, []) + + // 持久化 + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) + } + }, [settings]) + + // 生效的列(groupVisible 且至少有一列 visible 的分组,只含 visible 列) + const effectiveColumns = useMemo(() => { + return settings + .filter((g) => g.groupVisible && g.columns.some((c) => c.visible)) + .map((g) => ({ + group: g.group, + columns: g.columns.filter((c) => c.visible), + })) as ArchiveColumnGroup[] + }, [settings]) + + const toggleColumn = useCallback((group: string, prop: string) => { + setSettings((cur) => + cur.map((g) => + g.group === group + ? { ...g, columns: g.columns.map((c) => (c.prop === prop ? { ...c, visible: !c.visible } : c)) } + : g, + ), + ) + }, []) + + const toggleGroup = useCallback((group: string) => { + setSettings((cur) => + cur.map((g) => (g.group === group ? { ...g, groupVisible: !g.groupVisible } : g)), + ) + }, []) + + const setGroupAll = useCallback((group: string, visible: boolean) => { + setSettings((cur) => + cur.map((g) => + g.group === group ? { ...g, columns: g.columns.map((c) => ({ ...c, visible })) } : g, + ), + ) + }, []) + + const reset = useCallback(() => { + setSettings(buildDefault()) + }, []) + + return { settings, effectiveColumns, toggleColumn, toggleGroup, setGroupAll, reset, setSettings } +} diff --git a/furniture-3d-system/src/app/supply-chain/layout.tsx b/furniture-3d-system/src/app/supply-chain/layout.tsx new file mode 100644 index 00000000..87cc0b19 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/layout.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from 'react' +import { requireSession } from '@/lib/server-session' +import { SupplyChainLayout } from './components/SupplyChainLayout' + +export default async function SupplyChainRootLayout({ children }: { children: ReactNode }) { + await requireSession('/login') + return {children} +} diff --git a/furniture-3d-system/src/app/supply-chain/page.tsx b/furniture-3d-system/src/app/supply-chain/page.tsx new file mode 100644 index 00000000..eac3e700 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from 'next/navigation' + +export const metadata = { + title: '由壹点供应链中心', +} + +export default function SupplyChainPage() { + redirect('/supply-chain/dashboard') +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/components/PurchasePlaceholder.tsx b/furniture-3d-system/src/app/supply-chain/purchase/components/PurchasePlaceholder.tsx new file mode 100644 index 00000000..d9aee2a5 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/components/PurchasePlaceholder.tsx @@ -0,0 +1,50 @@ +'use client' + +import { ClipboardList } from 'lucide-react' + +export function PurchasePlaceholder({ title }: { title: string }) { + return ( +
+
+

{title}

+

内容待配置

+
+
+ +

建设中

+
+ +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/daily-affairs/page.tsx b/furniture-3d-system/src/app/supply-chain/purchase/daily-affairs/page.tsx new file mode 100644 index 00000000..61472d1a --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/daily-affairs/page.tsx @@ -0,0 +1,9 @@ +import { requireSession } from '@/lib/server-session' +import { PurchasePlaceholder } from '../components/PurchasePlaceholder' + +export const metadata = { title: '日常事项 · 供应链中心' } + +export default async function DailyAffairsPage() { + await requireSession('/login') + return +} diff --git a/furniture-3d-system/src/app/supply-chain/purchase/price-change/page.tsx b/furniture-3d-system/src/app/supply-chain/purchase/price-change/page.tsx new file mode 100644 index 00000000..5c668991 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/purchase/price-change/page.tsx @@ -0,0 +1,9 @@ +import { requireSession } from '@/lib/server-session' +import { PurchasePlaceholder } from '../components/PurchasePlaceholder' + +export const metadata = { title: '产品变价 · 供应链中心' } + +export default async function PriceChangePage() { + await requireSession('/login') + return +} diff --git a/furniture-3d-system/src/components/layout/AppShell.tsx b/furniture-3d-system/src/components/layout/AppShell.tsx index 312e8908..94f95151 100644 --- a/furniture-3d-system/src/components/layout/AppShell.tsx +++ b/furniture-3d-system/src/components/layout/AppShell.tsx @@ -31,6 +31,7 @@ export const APP_SHELL_ROOTS = [ '/warehouse-quality', '/ai-render-workbench', '/installation-demo-workbench', + '/supply-chain', ] as const export function shouldUseAppShell(pathname: string): boolean { @@ -104,6 +105,8 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe const isInstallationDemoWorkbenchRoute = pathname === '/installation-demo-workbench' || pathname.startsWith('/installation-demo-workbench/') + const isSupplyChainRoute = + pathname === '/supply-chain' || pathname.startsWith('/supply-chain/') const isProfileRoute = pathname === '/profile' || pathname.startsWith('/profile/') const isUsersRoute = pathname === '/users' || pathname.startsWith('/users/') const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/') @@ -123,7 +126,8 @@ function AppShellContent({ children, user, localOaBoardPreview = false }: AppShe isWarehouseRoute || isWarehouseQualityRoute || isAiRenderWorkbenchRoute || - isInstallationDemoWorkbenchRoute + isInstallationDemoWorkbenchRoute || + isSupplyChainRoute const shouldLockMainViewport = isWorkbenchShellRoute && !isOaBoardRoute && diff --git a/furniture-3d-system/src/components/layout/AppTopNav.tsx b/furniture-3d-system/src/components/layout/AppTopNav.tsx index 92c7637e..dae7792d 100644 --- a/furniture-3d-system/src/components/layout/AppTopNav.tsx +++ b/furniture-3d-system/src/components/layout/AppTopNav.tsx @@ -43,6 +43,8 @@ const WORKBENCH_NAV_ITEMS = [ { href: '/installation-demo-workbench', icon: Video, label: '安装演示' }, ] +import { SupplyChainSearch } from '@/app/supply-chain/components/SupplyChainSearch' + function matchesWorkbenchHref(pathname: string, href: string) { if (href === '/') return pathname === '/' return pathname === href || pathname.startsWith(`${href}/`) @@ -75,9 +77,13 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) { const shouldRestrictNavigation = user !== undefined && workspaceAccessLevel !== 'full' const brandHref = shouldRestrictNavigation ? FORMAL_PRODUCTS_HOME_PATH : '/' + const isHome = pathname === '/' + const isSupplyChain = pathname === '/supply-chain' || pathname.startsWith('/supply-chain/') const visibleNavItems = user === undefined ? WORKBENCH_NAV_ITEMS.filter((item) => item.href !== '/bom-costing') - : WORKBENCH_NAV_ITEMS.filter((item) => canAccessWorkspacePath(item.href, user)) + : isHome || isSupplyChain + ? WORKBENCH_NAV_ITEMS.filter((item) => item.href === '/') + : WORKBENCH_NAV_ITEMS.filter((item) => canAccessWorkspacePath(item.href, user)) const activeNavItem = visibleNavItems.find((item) => matchesWorkbenchHref(pathname, item.href)) ?? visibleNavItems[0] @@ -161,7 +167,7 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) { />

- {APP_BRAND_NAME} + {isSupplyChain ? '由壹点「供应链中心」' : APP_BRAND_NAME}

@@ -214,6 +220,7 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
+ {isSupplyChain && }