feat: 首页门户化 + 供应链中心框架
首页改门户选择页: - HomeClient.workspace 加门户短路,渲染 HomePortal(两个工作台入口) - HomePortal:产品开发(→/intake) + 供应链中心(→/supply-chain) 两大入口卡 - 顶部导航:首页时只显示「首页」,进入产品开发后恢复全部功能导航 - 入口图标:产品开发用 Package,供应链用 Truck - 时间问候语(早午晚好+用户名) 供应链中心(src/app/supply-chain/): - 独立布局:窄边栏(76px) + hover二级菜单浮层 + 多标签页 + 顶栏搜索 - 复用主项目顶栏(保留首页导航),供应链路由时品牌切换为「由壹点「供应链中心」」 - 全局菜单搜索:模糊匹配 + 拼音全拼/首字母 + 键盘导航(↑↓Enter Esc) - 4个页面:工作台(问候语)、产品全量档案、日常事项(空壳)、产品变价(空壳) - 产品全量档案:多级表头(5分组81字段) + 列设置抽屉(勾选显隐+拖拽排序+重置) + localStorage持久化 + 浮动分页 视觉:白曜石规范(浅色精密、克制通透、石墨灰强调色) 基于 origin/main,不含其他线程改动
This commit is contained in:
parent
5904e96494
commit
51e44d1eaf
@ -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 <HomePortal user={user} />
|
||||
|
||||
// ===== Stitch 移动端布局 =====
|
||||
if (isMobileOrTablet) {
|
||||
return (
|
||||
|
||||
93
furniture-3d-system/src/app/home/components/HomePortal.tsx
Normal file
93
furniture-3d-system/src/app/home/components/HomePortal.tsx
Normal file
@ -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 (
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-[#f4f6f6] font-[Manrope,sans-serif] tracking-tight text-[#2d3335]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_48%_42%,rgba(126,145,149,0.18),transparent_38%),linear-gradient(180deg,rgba(255,255,255,0.88),rgba(238,242,243,0.92))]" />
|
||||
<div className="absolute inset-0 opacity-55 bg-[linear-gradient(rgba(71,100,107,0.055)_1px,transparent_1px),linear-gradient(90deg,rgba(71,100,107,0.055)_1px,transparent_1px)] bg-[size:56px_56px]" />
|
||||
|
||||
<div className="relative z-10 flex h-full min-h-0 flex-col items-center justify-center gap-6 p-6 sm:gap-8 sm:p-10">
|
||||
<header className="text-center">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#47646b]/58">
|
||||
WORKSPACE
|
||||
</p>
|
||||
<h1 className="mt-2 text-[22px] font-black tracking-tight text-[#2d3335] sm:text-[26px]">
|
||||
{getGreeting()},{user?.name || '你好'}
|
||||
</h1>
|
||||
<p className="mt-2 text-[12px] font-semibold text-[#5a6062]/58 sm:text-[13px]">
|
||||
选择要进入的工作台
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid w-full max-w-[920px] gap-4 sm:gap-5 md:grid-cols-2">
|
||||
{PORTAL_ENTRIES.map((entry) => {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<Link
|
||||
key={entry.href}
|
||||
href={entry.href}
|
||||
className="group relative flex min-h-[180px] flex-col justify-between rounded-[12px] border border-[#dde3e5] bg-white/72 p-6 shadow-sm backdrop-blur-xl transition hover:-translate-y-0.5 hover:border-[#47646b]/24 hover:bg-white sm:min-h-[220px] sm:p-8"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-[10px] bg-[#2d3335] text-white shadow-sm sm:h-14 sm:w-14">
|
||||
<Icon className="h-6 w-6" strokeWidth={1.8} />
|
||||
</span>
|
||||
<ArrowRight className="h-5 w-5 text-[#47646b]/40 transition group-hover:translate-x-0.5 group-hover:text-[#2d3335]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[20px] font-black tracking-tight text-[#2d3335] sm:text-[24px]">
|
||||
{entry.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-[12px] font-semibold leading-relaxed text-[#5a6062]/58 sm:text-[13px]">
|
||||
{entry.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Boxes } from 'lucide-react'
|
||||
|
||||
export function SupplyChainClient() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-[var(--obs-shell-bg)]">
|
||||
<section className="mx-auto flex h-full w-full max-w-[640px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-[16px] bg-white/72 text-[var(--obs-shell-title)] shadow-sm ring-1 ring-black/5 backdrop-blur-xl">
|
||||
<Boxes className="h-7 w-7" />
|
||||
</div>
|
||||
<h1 className="mt-6 font-apple-semibold text-[20px] text-[var(--obs-shell-title)]">
|
||||
由壹点供应链中心
|
||||
</h1>
|
||||
<p className="mt-2 text-[13px] leading-[1.7] text-[var(--obs-shell-meta)]">
|
||||
供应链中心正在建设中,内容将随后续配置逐步开放。
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-flex items-center gap-1.5 rounded-[8px] bg-white/72 px-4 py-2 text-[13px] text-[var(--obs-shell-title)] shadow-sm ring-1 ring-black/5 backdrop-blur-xl transition hover:bg-white"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回首页
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<SupplyChainTabsProvider>
|
||||
<RouteTabSync>
|
||||
<div className="sc-layout">
|
||||
<SupplyChainSidebar />
|
||||
<div className="sc-layout-main">
|
||||
<SupplyChainTabBar />
|
||||
<div className="sc-layout-content">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</RouteTabSync>
|
||||
<style jsx global>{`
|
||||
.sc-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--sc-bg, #fafafa);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
color: var(--sc-ink, #27272a);
|
||||
}
|
||||
.sc-layout-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sc-layout-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sc-bg, #fafafa);
|
||||
}
|
||||
`}</style>
|
||||
</SupplyChainTabsProvider>
|
||||
)
|
||||
}
|
||||
@ -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<string, LucideIcon> = { 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<HTMLInputElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="sc-search-wrap" ref={wrapRef}>
|
||||
<Search size={15} strokeWidth={1.8} className="sc-search-icon" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="sc-search-input"
|
||||
placeholder="搜索菜单(支持拼音)"
|
||||
value={keyword}
|
||||
onChange={(e) => { setKeyword(e.target.value); setOpen(true) }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<div className="sc-search-dropdown">
|
||||
{results.map((r, i) => {
|
||||
const Icon = ICON_MAP[r.icon] ?? LayoutDashboard
|
||||
return (
|
||||
<div
|
||||
key={r.route}
|
||||
className={`sc-search-item ${i === hoverIdx ? 'active' : ''}`}
|
||||
onMouseEnter={() => setHoverIdx(i)}
|
||||
onClick={() => jump(r.route)}
|
||||
>
|
||||
<Icon size={15} strokeWidth={1.8} />
|
||||
<div className="sc-search-item-text">
|
||||
<span className="sc-search-item-label">{r.label}</span>
|
||||
<span className="sc-search-item-path">{r.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<style jsx>{`
|
||||
.sc-search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
background: #f4f4f5;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 18px;
|
||||
width: 260px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.sc-search-wrap:focus-within {
|
||||
background: #fff;
|
||||
border-color: #d4d4d8;
|
||||
}
|
||||
:global(.sc-search-icon) {
|
||||
color: #a1a1aa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sc-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: #27272a;
|
||||
}
|
||||
.sc-search-input::placeholder { color: #a1a1aa; }
|
||||
.sc-search-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(255,255,255,0.96);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sc-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #52525b;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.sc-search-item.active {
|
||||
background: rgba(82,82,91,0.08);
|
||||
color: #27272a;
|
||||
}
|
||||
.sc-search-item-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
:global(.sc-search-item-label) { font-size: 13px; font-weight: 500; }
|
||||
:global(.sc-search-item-path) { font-size: 11px; color: #a1a1aa; }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<string, LucideIcon> = {
|
||||
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 (
|
||||
<aside className="sc-sidebar">
|
||||
<nav className="sc-sidebar-nav">
|
||||
{SUPPLY_CHAIN_MENU.map((mod) => {
|
||||
const Icon = ICON_MAP[mod.icon] ?? LayoutDashboard
|
||||
const active = isModuleActive(pathname, mod)
|
||||
|
||||
if (mod.sections) {
|
||||
return (
|
||||
<div key={mod.key} className={`sc-nav-icon-group ${active ? 'active' : ''}`}>
|
||||
<div className="sc-nav-icon">
|
||||
<Icon size={20} strokeWidth={1.8} />
|
||||
<span className="sc-nav-label">{mod.label}</span>
|
||||
</div>
|
||||
<div className="sc-flyout">
|
||||
<div className="sc-flyout-module">{mod.label}</div>
|
||||
{mod.sections.map((section, sIdx) => (
|
||||
<div key={sIdx} className="sc-flyout-section">
|
||||
{section.title && <div className="sc-flyout-section-title">{section.title}</div>}
|
||||
{section.items.map((item) => {
|
||||
const itemActive = isItemActive(pathname, item.route)
|
||||
return (
|
||||
<Link
|
||||
key={item.route}
|
||||
href={item.route}
|
||||
className={`sc-flyout-item ${itemActive ? 'active' : ''}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={mod.key}
|
||||
href={mod.route!}
|
||||
className={`sc-nav-icon-link ${active ? 'active' : ''}`}
|
||||
>
|
||||
<div className="sc-nav-icon">
|
||||
<Icon size={20} strokeWidth={1.8} />
|
||||
<span className="sc-nav-label">{mod.label}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<style jsx>{`
|
||||
.sc-sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 76px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e4e4e7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
gap: 4px;
|
||||
}
|
||||
.sc-sidebar-logo {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
:global(.sc-sidebar-logo-mark) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: #27272a;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 4px 10px rgba(39,39,42,0.18);
|
||||
}
|
||||
.sc-sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
:global(.sc-nav-icon-link),
|
||||
:global(.sc-nav-icon-group) {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
cursor: pointer;
|
||||
}
|
||||
:global(.sc-nav-icon) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
border-radius: 8px;
|
||||
color: #52525b;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
:global(.sc-nav-icon-link:hover .sc-nav-icon),
|
||||
:global(.sc-nav-icon-group:hover .sc-nav-icon) {
|
||||
background: #f4f4f5;
|
||||
color: #27272a;
|
||||
}
|
||||
:global(.sc-nav-icon-link.active .sc-nav-icon),
|
||||
:global(.sc-nav-icon-group.active .sc-nav-icon) {
|
||||
color: #27272a;
|
||||
background: rgba(82,82,91,0.08);
|
||||
}
|
||||
:global(.sc-nav-label) {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* hover 二级浮层 */
|
||||
:global(.sc-flyout) {
|
||||
position: fixed;
|
||||
left: 76px;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
width: 208px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(16px);
|
||||
border-right: 1px solid #e4e4e7;
|
||||
padding: 20px 12px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(-6px);
|
||||
transition: opacity 0.18s, transform 0.18s, visibility 0.18s;
|
||||
z-index: 50;
|
||||
box-shadow: 4px 0 16px rgba(0,0,0,0.04);
|
||||
}
|
||||
:global(.sc-nav-icon-group:hover .sc-flyout) {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(0);
|
||||
}
|
||||
:global(.sc-flyout-module) {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #27272a;
|
||||
padding: 0 8px 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
:global(.sc-flyout-section-title) {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #a1a1aa;
|
||||
padding: 8px 8px 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
:global(.sc-flyout-item) {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #3f3f46;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
:global(.sc-flyout-item:hover) {
|
||||
background: #f4f4f5;
|
||||
color: #27272a;
|
||||
}
|
||||
:global(.sc-flyout-item.active) {
|
||||
background: rgba(82,82,91,0.1);
|
||||
color: #27272a;
|
||||
font-weight: 600;
|
||||
}
|
||||
`}</style>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<div className="sc-tabbar-wrap">
|
||||
<div className="sc-tabbar">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.path === activePath
|
||||
return (
|
||||
<div
|
||||
key={tab.path}
|
||||
className={`sc-tab ${active ? 'active' : ''}`}
|
||||
onClick={() => clickTab(tab.path)}
|
||||
>
|
||||
<span className="sc-tab-title">{tab.title}</span>
|
||||
{tab.closable && (
|
||||
<button
|
||||
className="sc-tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.path)
|
||||
}}
|
||||
aria-label="关闭标签"
|
||||
>
|
||||
<X size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.sc-tabbar-wrap {
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f4f4f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.sc-tabbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.sc-tabbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.sc-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #52525b;
|
||||
background: #f4f4f5;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sc-tab:hover {
|
||||
background: #e4e4e7;
|
||||
color: #27272a;
|
||||
}
|
||||
.sc-tab.active {
|
||||
background: #27272a;
|
||||
color: #fff;
|
||||
}
|
||||
.sc-tab-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
.sc-tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
.sc-tab-close:hover {
|
||||
opacity: 1;
|
||||
background: rgba(239,68,68,0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
.sc-tab.active .sc-tab-close {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.sc-tab.active .sc-tab-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<typeof flattenSupplyChainMenu>[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<FlatItem & { score: number }> {
|
||||
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)
|
||||
}
|
||||
@ -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<TabsContextValue | null>(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<SupplyChainTab[]>([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 (
|
||||
<TabsContext.Provider value={{ tabs, activePath: pathname, addTab, closeTab, clickTab }}>
|
||||
{children}
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSupplyChainTabs() {
|
||||
const ctx = useContext(TabsContext)
|
||||
if (!ctx) throw new Error('useSupplyChainTabs 必须在 SupplyChainTabsProvider 内使用')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// 路径到标题的映射(用于自动建标签)
|
||||
export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
|
||||
'/supply-chain/dashboard': '工作台',
|
||||
'/supply-chain/database/product-archive': '产品全量档案',
|
||||
'/supply-chain/purchase/daily-affairs': '日常事项',
|
||||
'/supply-chain/purchase/price-change': '产品变价',
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 (
|
||||
<div className="sc-dashboard">
|
||||
<div className="sc-dashboard-hero">
|
||||
<h1>{getGreeting()},{username || '你好'}</h1>
|
||||
<p>欢迎回到供应链中心</p>
|
||||
<div className="sc-dashboard-date">
|
||||
<Calendar size={15} strokeWidth={1.8} />
|
||||
<span>{getToday()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.sc-dashboard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100dvh - 40px);
|
||||
padding: 40px 24px;
|
||||
}
|
||||
.sc-dashboard-hero {
|
||||
text-align: center;
|
||||
}
|
||||
.sc-dashboard-hero h1 {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
color: #27272a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sc-dashboard-hero p {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: #52525b;
|
||||
}
|
||||
.sc-dashboard-date {
|
||||
margin-top: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e4e7;
|
||||
font-size: 13px;
|
||||
color: #52525b;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 <DashboardView username={authUser.name} />
|
||||
}
|
||||
@ -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 (
|
||||
<>
|
||||
{/* 遮罩 */}
|
||||
<div className="csp-overlay" onClick={onClose} />
|
||||
{/* 抽屉 */}
|
||||
<div className="csp-drawer">
|
||||
<div className="csp-head">
|
||||
<div>
|
||||
<div className="csp-title">列设置</div>
|
||||
<div className="csp-sub">勾选显示,拖拽排序</div>
|
||||
</div>
|
||||
<button className="csp-close" onClick={onClose} aria-label="关闭"><X size={18} strokeWidth={1.8} /></button>
|
||||
</div>
|
||||
|
||||
<div className="csp-actions">
|
||||
<button className="csp-reset" onClick={onReset}><RotateCcw size={13} strokeWidth={1.8} />重置默认</button>
|
||||
</div>
|
||||
|
||||
<div className="csp-list">
|
||||
{settings.map((g, gi) => (
|
||||
<div key={g.group} className={`csp-group ${!g.groupVisible ? 'hidden' : ''}`}>
|
||||
<div className="csp-group-head"
|
||||
draggable
|
||||
onDragStart={() => onDragStartGroup(gi)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDropGroup(gi)}
|
||||
>
|
||||
<span className="csp-drag"><GripVertical size={14} /></span>
|
||||
<label className="csp-check">
|
||||
<input type="checkbox" checked={g.groupVisible} onChange={() => toggleGroup(g.group)} />
|
||||
<Check size={13} className="csp-check-icon" />
|
||||
<span>{g.group}</span>
|
||||
</label>
|
||||
<span className="csp-count">{g.columns.filter((c) => c.visible).length}/{g.columns.length}</span>
|
||||
<button className="csp-all-btn" onClick={() => setGroupAll(g.group, !isGroupAll(g))}>
|
||||
{isGroupAll(g) ? '全不选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{g.groupVisible && (
|
||||
<div className="csp-cols">
|
||||
{g.columns.map((c, ci) => (
|
||||
<div key={c.prop} className={`csp-col ${!c.visible ? 'hidden' : ''}`}
|
||||
draggable
|
||||
onDragStart={() => onDragStartCol(g.group, ci)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDropCol(g.group, ci)}
|
||||
>
|
||||
<span className="csp-drag"><GripVertical size={13} /></span>
|
||||
<label className="csp-check">
|
||||
<input type="checkbox" checked={c.visible} onChange={() => toggleColumn(g.group, c.prop)} />
|
||||
<Check size={13} className="csp-check-icon" />
|
||||
<span>{c.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{msg && <div className="csp-msg">{msg}</div>}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.csp-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.25);
|
||||
z-index: 200;
|
||||
}
|
||||
.csp-drawer {
|
||||
position: fixed; right: 0; top: 0; bottom: 0;
|
||||
width: 360px; background: #fff;
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,0.08);
|
||||
z-index: 201;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.csp-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid #f4f4f5;
|
||||
}
|
||||
.csp-title { font-size: 15px; font-weight: 600; color: #27272a; }
|
||||
.csp-sub { font-size: 12px; color: #a1a1aa; margin-top: 2px; }
|
||||
.csp-close {
|
||||
border: none; background: transparent; color: #a1a1aa;
|
||||
cursor: pointer; padding: 2px; border-radius: 4px;
|
||||
}
|
||||
.csp-close:hover { color: #27272a; background: #f4f4f5; }
|
||||
.csp-actions {
|
||||
display: flex; justify-content: flex-end; padding: 12px 20px;
|
||||
}
|
||||
.csp-reset {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 5px 10px; background: #fff; border: 1px solid #e4e4e7;
|
||||
border-radius: 6px; font-size: 12px; color: #52525b; cursor: pointer;
|
||||
}
|
||||
.csp-reset:hover { background: #f4f4f5; }
|
||||
.csp-list {
|
||||
flex: 1; overflow-y: auto; padding: 0 20px 20px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.csp-group {
|
||||
border: 1px solid #f4f4f5; border-radius: 6px; overflow: hidden;
|
||||
}
|
||||
.csp-group.hidden { opacity: 0.5; }
|
||||
.csp-group-head {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 10px; background: #f4f4f5;
|
||||
}
|
||||
:global(.csp-drag) {
|
||||
color: #a1a1aa; cursor: grab; display: inline-flex;
|
||||
}
|
||||
:global(.csp-drag:active) { cursor: grabbing; }
|
||||
.csp-check {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
cursor: pointer; font-size: 13px; color: #27272a;
|
||||
position: relative;
|
||||
}
|
||||
.csp-check input {
|
||||
appearance: none; width: 16px; height: 16px;
|
||||
border: 1.5px solid #d4d4d8; border-radius: 3px;
|
||||
background: #fff; cursor: pointer; position: relative;
|
||||
margin: 0;
|
||||
}
|
||||
.csp-check input:checked { background: #27272a; border-color: #27272a; }
|
||||
:global(.csp-check-icon) {
|
||||
position: absolute; left: 1.5px; top: 1.5px;
|
||||
color: #fff; pointer-events: none;
|
||||
}
|
||||
.csp-check input:not(:checked) ~ :global(.csp-check-icon) { display: none; }
|
||||
.csp-count { font-size: 11px; color: #a1a1aa; }
|
||||
.csp-all-btn {
|
||||
margin-left: auto; background: none; border: none;
|
||||
font-size: 12px; color: #52525b; cursor: pointer;
|
||||
}
|
||||
.csp-all-btn:hover { color: #27272a; }
|
||||
.csp-cols { display: flex; flex-direction: column; padding: 4px; }
|
||||
.csp-col {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 8px; border-radius: 4px;
|
||||
}
|
||||
.csp-col:hover { background: #f4f4f5; }
|
||||
.csp-col.hidden { opacity: 0.5; }
|
||||
.csp-msg {
|
||||
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||
padding: 6px 14px; background: #27272a; color: #fff;
|
||||
border-radius: 6px; font-size: 12px;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<div className="archive-page">
|
||||
{/* 工具条 */}
|
||||
<div className="archive-toolbar">
|
||||
<span className="archive-toolbar-title">产品全量档案</span>
|
||||
<span className="archive-toolbar-sub">
|
||||
显示 {visibleFieldCount} / {totalFieldCount} 个字段 · {effectiveColumns.length} 个分组
|
||||
</span>
|
||||
<button className="archive-toolbar-action" onClick={() => setShowColumnSettings(true)}>
|
||||
<Settings size={14} strokeWidth={1.8} />
|
||||
列设置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="archive-table-wrap">
|
||||
<table className="archive-table">
|
||||
<thead>
|
||||
{/* 一级表头:分组 */}
|
||||
<tr>
|
||||
<th rowSpan={2} className="archive-th-index">序号</th>
|
||||
{effectiveColumns.map((g) => (
|
||||
<th key={g.group} colSpan={g.columns.length} className="archive-th-group" style={{ background: GROUP_COLORS[g.group] || undefined }}>
|
||||
{g.group}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{/* 二级表头:字段 */}
|
||||
<tr>
|
||||
{effectiveColumns.map((g) =>
|
||||
g.columns.map((c) => (
|
||||
<th key={c.prop} className="archive-th-field" style={{ minWidth: c.width }}>
|
||||
{c.label}
|
||||
</th>
|
||||
)),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{/* 空数据,后续接入 */}</tbody>
|
||||
</table>
|
||||
{total === 0 && <div className="archive-empty">暂无数据</div>}
|
||||
</div>
|
||||
|
||||
{/* 分页(浮动胶囊) */}
|
||||
<div className="archive-pagination">
|
||||
<span className="archive-page-total">共 {total} 条</span>
|
||||
<button className="archive-page-btn" disabled={currentPage <= 1} onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}>
|
||||
<ArrowLeft size={12} strokeWidth={1.8} />
|
||||
上一页
|
||||
</button>
|
||||
<span className="archive-page-num">
|
||||
<span className="archive-page-current">{currentPage}</span>
|
||||
<span className="archive-page-sep">/</span>
|
||||
<span>{totalPages} 页</span>
|
||||
</span>
|
||||
<button className="archive-page-btn" disabled={currentPage >= totalPages} onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}>
|
||||
下一页
|
||||
<ArrowRight size={12} strokeWidth={1.8} />
|
||||
</button>
|
||||
<span className="archive-page-size-wrap">
|
||||
每页
|
||||
<select className="archive-page-size" value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setCurrentPage(1) }}>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ColumnSettingsPanel open={showColumnSettings} onClose={() => setShowColumnSettings(false)} />
|
||||
|
||||
<style jsx>{`
|
||||
.archive-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
.archive-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #f4f4f5;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
}
|
||||
.archive-toolbar-title { font-size: 15px; font-weight: 600; color: #27272a; }
|
||||
.archive-toolbar-sub { font-size: 12px; color: #a1a1aa; }
|
||||
.archive-toolbar-action {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #3f3f46;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.archive-toolbar-action:hover { background: #f4f4f5; border-color: #a1a1aa; }
|
||||
.archive-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #f4f4f5;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
position: relative;
|
||||
}
|
||||
.archive-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.archive-table :global(.archive-th-index) {
|
||||
position: sticky; left: 0; z-index: 3;
|
||||
padding: 8px; text-align: center;
|
||||
background: #fff; border: 1px solid #e4e4e7;
|
||||
font-weight: 600; color: #27272a;
|
||||
min-width: 60px;
|
||||
}
|
||||
:global(.archive-th-group) {
|
||||
padding: 8px; text-align: center;
|
||||
border: 1px solid #e4e4e7;
|
||||
font-weight: 600; font-size: 13px; color: #27272a;
|
||||
}
|
||||
:global(.archive-th-field) {
|
||||
padding: 8px; text-align: center;
|
||||
border: 1px solid #e4e4e7;
|
||||
font-weight: 500; color: #3f3f46;
|
||||
background: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.archive-empty {
|
||||
padding: 60px 20px; text-align: center;
|
||||
color: #a1a1aa; font-size: 13px;
|
||||
}
|
||||
/* 分页浮动胶囊(绝对定位浮在表格上方,不占布局空间) */
|
||||
.archive-pagination {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06), 0 2px 4px rgba(0,0,0,0.04);
|
||||
font-size: 13px;
|
||||
z-index: 30;
|
||||
}
|
||||
.archive-page-total { color: #52525b; margin-right: 8px; }
|
||||
.archive-page-btn {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
height: 30px; padding: 0 12px;
|
||||
background: #fff; border: 1px solid #e4e4e7; border-radius: 4px;
|
||||
color: #27272a; font-size: 13px; cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.archive-page-btn:hover:not(:disabled) { border-color: #52525b; background: #f4f4f5; }
|
||||
.archive-page-btn:disabled { color: #a1a1aa; cursor: not-allowed; opacity: 0.5; }
|
||||
.archive-page-num { display: inline-flex; align-items: center; gap: 4px; color: #52525b; }
|
||||
.archive-page-current {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 28px; height: 28px; padding: 0 8px;
|
||||
background: #fff; border: 1px solid #e4e4e7; border-radius: 4px;
|
||||
color: #27272a; font-weight: 600;
|
||||
}
|
||||
.archive-page-sep { color: #a1a1aa; }
|
||||
.archive-page-size-wrap { display: inline-flex; align-items: center; gap: 6px; margin-left: 8px; color: #52525b; }
|
||||
.archive-page-size {
|
||||
height: 28px; padding: 0 8px;
|
||||
background: #fff; border: 1px solid #e4e4e7; border-radius: 4px;
|
||||
color: #27272a; font-size: 13px; cursor: pointer; outline: none;
|
||||
}
|
||||
.archive-page-size:focus { border-color: #52525b; }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
商品信息: '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)', // 浅粉
|
||||
}
|
||||
@ -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 <ProductArchiveView />
|
||||
}
|
||||
@ -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<ColumnSetting[]>(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 }
|
||||
}
|
||||
8
furniture-3d-system/src/app/supply-chain/layout.tsx
Normal file
8
furniture-3d-system/src/app/supply-chain/layout.tsx
Normal file
@ -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 <SupplyChainLayout>{children}</SupplyChainLayout>
|
||||
}
|
||||
9
furniture-3d-system/src/app/supply-chain/page.tsx
Normal file
9
furniture-3d-system/src/app/supply-chain/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const metadata = {
|
||||
title: '由壹点供应链中心',
|
||||
}
|
||||
|
||||
export default function SupplyChainPage() {
|
||||
redirect('/supply-chain/dashboard')
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { ClipboardList } from 'lucide-react'
|
||||
|
||||
export function PurchasePlaceholder({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="sc-page">
|
||||
<div className="sc-page-header">
|
||||
<h1>{title}</h1>
|
||||
<p>内容待配置</p>
|
||||
</div>
|
||||
<div className="sc-empty">
|
||||
<ClipboardList size={40} strokeWidth={1.5} />
|
||||
<p>建设中</p>
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.sc-page {
|
||||
padding: 20px 24px;
|
||||
min-height: calc(100dvh - 40px);
|
||||
}
|
||||
.sc-page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.sc-page-header h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #27272a;
|
||||
}
|
||||
.sc-page-header p {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
.sc-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-height: 320px;
|
||||
color: #a1a1aa;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f4f4f5;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 <PurchasePlaceholder title="日常事项" />
|
||||
}
|
||||
@ -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 <PurchasePlaceholder title="产品变价" />
|
||||
}
|
||||
@ -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 &&
|
||||
|
||||
@ -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) {
|
||||
/>
|
||||
<div className="leading-none">
|
||||
<p className="whitespace-nowrap text-[15px] font-black leading-none text-[#2d3335]">
|
||||
{APP_BRAND_NAME}
|
||||
{isSupplyChain ? '由壹点「供应链中心」' : APP_BRAND_NAME}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
@ -214,6 +220,7 @@ export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
|
||||
</div>
|
||||
|
||||
<div className="ml-auto hidden shrink-0 items-center gap-3 border-l border-black/[0.04] pl-6 sm:flex">
|
||||
{isSupplyChain && <SupplyChainSearch />}
|
||||
<Link
|
||||
href="/release-notes"
|
||||
aria-label="更新日志"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user