From f1b3f8d3da38c82b731510568a73ad935c226707 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Mon, 28 Sep 2026 12:29:51 +0800 Subject: [PATCH] feat(assets): add dimension analysis and authorized user previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation. --- .gitignore | 3 + docs/permission-preview.md | 29 ++ src/App.tsx | 14 +- src/app/routing.test.ts | 2 + src/app/routing.ts | 3 +- src/auth/AuthProvider.tsx | 171 ++++++-- src/auth/PermissionPreview.css | 31 ++ src/auth/PermissionPreview.tsx | 85 ++++ src/auth/api-client.ts | 66 +++- src/auth/useAuth.ts | 38 +- src/components/FeedbackFab.tsx | 6 +- src/modules/admin/FeedbackAdminPage.tsx | 7 +- src/modules/assets/AssetsModule.tsx | 95 +---- src/modules/assets/analytics-model.test.ts | 208 ++++++++++ src/modules/assets/analytics-model.ts | 365 ++++++++++++++++++ .../assets/assistant/AssetAssistant.tsx | 15 +- src/modules/assets/assistant/assistant.css | 18 - .../assistant/result-presentation.test.ts | 2 +- .../assets/assistant/result-presentation.ts | 2 +- .../components/AbnormalVehicleDetails.tsx | 4 +- .../assets/components/FlowAnalysisModal.tsx | 159 ++++++++ .../assets/components/FlowDetailModal.tsx | 4 +- .../components/analysis/AnalysisControls.tsx | 89 +++++ .../analysis/AssetAnalysisModal.tsx | 270 +++++++++++++ .../components/analysis/DimensionPicker.tsx | 77 ++++ .../analysis/ProgressiveRowsStatus.tsx | 18 + .../components/analysis/asset-analysis.css | 172 +++++++++ .../components/analysis/dimension-picker.css | 34 ++ .../components/analysis/progressive-rows.css | 5 + .../components/analysis/useProgressiveRows.ts | 48 +++ .../assets/components/flow-analysis-modal.css | 86 +++++ .../overview/InventoryDesktopTable.tsx | 252 +++++++----- .../overview/InventoryMobileList.tsx | 198 ++++++---- .../overview/InventoryStatisticsSection.tsx | 296 +++++++++----- .../overview/InventoryStatusCounts.tsx | 11 +- .../components/overview/SummaryMetrics.tsx | 4 +- .../overview/inventory-presentation.test.ts | 21 +- .../overview/inventory-presentation.ts | 8 +- src/modules/assets/dimension-layout.test.ts | 94 +++++ src/modules/assets/dimension-layout.ts | 97 +++++ src/modules/ele/EleImportPage.tsx | 10 +- src/server/app.ts | 3 + src/server/auth/login.ts | 16 +- src/server/auth/manager-identity.test.ts | 17 + src/server/auth/manager-identity.ts | 20 + src/server/auth/middleware.ts | 27 +- src/server/auth/permissions.ts | 2 +- src/server/auth/preview/directory.ts | 61 +++ src/server/auth/preview/preview.test.ts | 149 +++++++ .../auth/preview/read-only-pages.test.ts | 56 +++ src/server/auth/preview/routes.ts | 68 ++++ src/server/auth/preview/service.ts | 99 +++++ src/server/auth/types.ts | 4 + src/server/routes/asset-assistant/gaps.ts | 10 +- src/server/routes/asset-assistant/index.ts | 13 +- src/server/routes/asset-assistant/session.ts | 3 + src/server/routes/ele/index.ts | 16 +- src/server/routes/feedback/index.ts | 13 +- .../mileage/daily-report-preview.test.ts | 46 +++ .../routes/mileage/daily-report-service.ts | 6 +- .../routes/mileage/daily-report-store.ts | 29 +- src/server/routes/mileage/daily-report.ts | 13 +- src/server/routes/vehicles.ts | 84 +++- src/server/routes/vehicles/analytics.test.ts | 271 +++++++++++++ src/server/routes/vehicles/analytics.ts | 181 +++++++++ src/server/routes/vehicles/repository.test.ts | 1 + src/shared/assets/abnormal-move.ts | 4 + src/shared/assets/analytics.ts | 71 ++++ src/shared/auth/roles.ts | 7 + 69 files changed, 3871 insertions(+), 536 deletions(-) create mode 100644 docs/permission-preview.md create mode 100644 src/auth/PermissionPreview.css create mode 100644 src/auth/PermissionPreview.tsx create mode 100644 src/modules/assets/analytics-model.test.ts create mode 100644 src/modules/assets/analytics-model.ts create mode 100644 src/modules/assets/components/FlowAnalysisModal.tsx create mode 100644 src/modules/assets/components/analysis/AnalysisControls.tsx create mode 100644 src/modules/assets/components/analysis/AssetAnalysisModal.tsx create mode 100644 src/modules/assets/components/analysis/DimensionPicker.tsx create mode 100644 src/modules/assets/components/analysis/ProgressiveRowsStatus.tsx create mode 100644 src/modules/assets/components/analysis/asset-analysis.css create mode 100644 src/modules/assets/components/analysis/dimension-picker.css create mode 100644 src/modules/assets/components/analysis/progressive-rows.css create mode 100644 src/modules/assets/components/analysis/useProgressiveRows.ts create mode 100644 src/modules/assets/components/flow-analysis-modal.css create mode 100644 src/modules/assets/dimension-layout.test.ts create mode 100644 src/modules/assets/dimension-layout.ts create mode 100644 src/server/auth/manager-identity.test.ts create mode 100644 src/server/auth/manager-identity.ts create mode 100644 src/server/auth/preview/directory.ts create mode 100644 src/server/auth/preview/preview.test.ts create mode 100644 src/server/auth/preview/read-only-pages.test.ts create mode 100644 src/server/auth/preview/routes.ts create mode 100644 src/server/auth/preview/service.ts create mode 100644 src/server/routes/mileage/daily-report-preview.test.ts create mode 100644 src/server/routes/vehicles/analytics.test.ts create mode 100644 src/server/routes/vehicles/analytics.ts create mode 100644 src/shared/assets/analytics.ts diff --git a/.gitignore b/.gitignore index 36c51ee..18109e9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist # Local machine metadata and runtime logs .DS_Store *.log + +# Permission-preview audit trail (operator/target identifiers) +logs/permission-preview.jsonl diff --git a/docs/permission-preview.md b/docs/permission-preview.md new file mode 100644 index 0000000..a32a467 --- /dev/null +++ b/docs/permission-preview.md @@ -0,0 +1,29 @@ +# 权限视角预览 + +授权账号登录后,直接访问 `/who` 搜索同一组织的有效用户,先核对角色与数据范围,再进入只读预览。常规页面不展示入口,未授权用户直接访问链接也无法使用。预览使用目标账号真实角色:BI-Leader/所有权限/数智中心为全量,BI-Leader-Dep为本部门,其余为本人负责;多角色时全量优先。保持各业务模块现有的范围规则,库存公共汇总等不会因此变为个人数据。 + +## 授权与身份来源 + +- 默认授权已核实的旧SSO账号:`puhongxia`(`1014807041625047040`)、`zhanglan`(`1105261382487539712`)、`shishengliang`(`1116631120763437056`)。 +- 后续通过服务端 `BI_PERMISSION_PREVIEW_USER_IDS` 配置逗号分隔的用户ID名单;该配置**替换**默认名单。空字符串关闭全部授权。不要用姓名、前端配置或请求参数授予权限。 +- 身份与角色读取现有SSO库 `lingniu_prod`,可由 `BI_IDENTITY_SCHEMA` 指定;仅读取启用且未删除的用户、未删除角色和部门。不读取密码、电话等字段。 +- 新车辆系统 `ry-cloud` 的账号ID不是现有BI登录ID,不能直接填入授权名单,也不能按同名人拼接身份。个人车辆范围通过唯一、大小写精确相同的登录名匹配两套有效账号,正常登录与预览共用同一过滤;缺失或歧义不扩权。新用户租户由 `BI_VEHICLE_IDENTITY_TENANT_ID` 指定(默认已核实的 `000000`),部门仍采用原BI登录目录的部门。 +- 本地免登录默认无预览权限。用户明确要求后,可在本地 `.env` 同时开启 `DEV_BYPASS_AUTH=1`、`DEV_PERMISSION_PREVIEW=1`;仅非生产模式生效。可由 `DEV_PERMISSION_PREVIEW_USER_ID` 指定已授权的组织范围锚点(默认puhongxia ID),操作与审计身份仍为dev-local。局域网访问此开发服务也能使用该入口。真实用户授权验收仍需业务系统正常SSO登录。 + +## 会话与隔离 + +- 人员查询仅在输入关键词后返回模糊匹配结果;空白关键词不展示也不返回人员列表,范围筛选不能单独枚举人员。 + +- 原登录Bearer始终代表操作者。预览使用独立随机令牌 `X-BI-Preview`,有效期30分钟,绑定操作者和目标用户;原JWT不被替换。 +- 服务端每次请求复核操作者授权、用户有效性、同组织关系、目标角色与部门。原身份与目标权限不做并集。 +- 切换会取消旧请求并重建页面,AI会话还按操作者及预览会话ID隔离;退出恢复原账号。写入接口及缓存刷新、调试接口禁止在预览中调用。 +- AI问数只查询,不自动记录需求。日报预览只读获取现有归档或内存生成,不建表、不保存归档。 +- 预览会话目前保存在服务进程内存,重启后失效,多实例部署需会话粘滞或先迁移到共享会话存储。 + +## 审计 + +默认追加到 `logs/permission-preview.jsonl`;可用 `BI_PERMISSION_PREVIEW_AUDIT_FILE` 配置持久化日志路径。记录开始、读取、阻止写入、退出,以及操作者ID、目标ID、时间、路径和范围,不记录令牌或业务数据。审计写入失败时拒绝预览请求。生产部署应挂载持久卷并由日志平台采集/轮转。 + +## 验收 + +验证未授权者拒绝、三类范围、目标失效、角色变动、跨组织拒绝、令牌伪造/过期/跨操作者复用、只读拦截和AI会话隔离。真实账号端到端登录仍需业务系统SSO会话;本地免登录不能替代此项。 diff --git a/src/App.tsx b/src/App.tsx index a40f88d..779ba90 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { Shell } from "./components/Shell"; import AuthProvider from "./auth/AuthProvider"; +import PermissionPreview, { PermissionPreviewPage } from "./auth/PermissionPreview"; import { useAuth } from "./auth/useAuth"; import UnauthorizedPage from "./auth/UnauthorizedPage"; import { canAccessEnergy } from "./shared/auth/roles"; @@ -34,8 +35,8 @@ function AuthGate() { }, []); useEffect(() => { - document.title = pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI"; - }, [pathSet]); + document.title = routeKey === "who" ? "权限视角 - 羚牛氢能 BI" : pathSet === "energy" ? "羚牛氢能-能源BI" : "羚牛氢能-资产BI"; + }, [pathSet, routeKey]); const modules = useMemo( () => buildModules(pathSet, user?.roles), @@ -68,6 +69,8 @@ function AuthGate() { return ; } + if (routeKey === "who") return ; + // 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现 if (routeKey === "ele/import") { return ( @@ -93,10 +96,15 @@ function AuthGate() { return ; } +function AuthenticatedWorkspace() { + const { identityVersion = 0 } = useAuth(); + return ; +} + export default function App() { return ( - + ); } diff --git a/src/app/routing.test.ts b/src/app/routing.test.ts index d2f5a64..d58ab95 100644 --- a/src/app/routing.test.ts +++ b/src/app/routing.test.ts @@ -9,6 +9,7 @@ test('normalizes legacy asset paths without losing search parameters', () => { test('keeps canonical and hidden administration paths unchanged', () => { assert.equal(resolveCanonicalUrl({ pathname: '/asset', search: '', hash: '#assets' }), null); assert.equal(resolveCanonicalUrl({ pathname: '/admin/feedback', search: '', hash: '' }), null); + assert.equal(resolveCanonicalUrl({ pathname: '/who', search: '', hash: '' }), null); }); test('derives path groups and hidden routes from path or hash', () => { @@ -16,5 +17,6 @@ test('derives path groups and hidden routes from path or hash', () => { assert.equal(getPathSet('/asset'), 'asset'); assert.equal(getRouteKey('/asset', '#/ele/import'), 'ele/import'); assert.equal(getRouteKey('/admin/feedback', ''), 'admin/feedback'); + assert.equal(getRouteKey('/who', ''), 'who'); assert.equal(getRouteKey('/asset', '#mileage'), ''); }); diff --git a/src/app/routing.ts b/src/app/routing.ts index c7abf66..7b4527d 100644 --- a/src/app/routing.ts +++ b/src/app/routing.ts @@ -5,7 +5,7 @@ interface BrowserLocation { search: string; hash: string; } -const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback']); +const ROOT_PATHS = new Set(['/asset', '/energy', '/ele/import', '/admin/feedback', '/who']); const LEGACY_PATHS: Record = { '/': { path: '/asset' }, @@ -35,6 +35,7 @@ export function getPathSet(pathname: string): PathSet { } export function getRouteKey(pathname: string, hash: string): string { + if (pathname === '/who') return 'who'; if (pathname === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') { return 'ele/import'; } diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index 9da7247..be5a7b2 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, type ReactNode } from 'react'; -import { AuthContext, type AuthState } from './useAuth'; -import { setTokenGetter } from './api-client'; +import { AuthContext, type AuthState, type AuthUser } from './useAuth'; +import { setTokenGetter, setPreviewTokenGetter, invalidateAuthenticatedRequests, fetchPreviewControl } from './api-client'; const AUTH_API = '/api/auth'; @@ -13,43 +13,147 @@ export default function AuthProvider({ children }: { children: ReactNode }) { }); const tokenRef = useRef(null); const authStarted = useRef(false); + const previewTokenRef = useRef(null); + const actorRef = useRef(null); + const transitionRef = useRef(0); + const [identityVersion, setIdentityVersion] = useState(0); + const [preview, setPreview] = useState<{ user: AuthUser; expiresAt: string } | null>(null); + const [canPreview, setCanPreview] = useState(false); + const [previewNotice, setPreviewNotice] = useState(null); + + function resetPreview(message: string | null = null) { + transitionRef.current += 1; + previewTokenRef.current = null; + sessionStorage.removeItem('bi_preview_token'); + invalidateAuthenticatedRequests(); + setIdentityVersion(v => v + 1); + setPreview(null); + setPreviewNotice(message); + } + + async function loadPreviewContext(user: AuthUser | null) { + actorRef.current = user; + try { + const context = await fetchPreviewControl<{ canPreview: boolean; actor: AuthUser }>('context'); + actorRef.current = context.actor; + setCanPreview(context.canPreview); + if (context.canPreview) { + const savedPreview = sessionStorage.getItem('bi_preview_token'); + if (savedPreview) { + previewTokenRef.current = savedPreview; + try { + const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true); + setPreview(current); + } catch { + resetPreview('上次权限视角已失效,已返回本人视角。'); + } + } + } else { + resetPreview(); + } + } catch { + resetPreview(); + setCanPreview(false); + } + } + + async function finishAuthentication(user: AuthUser | null) { + await loadPreviewContext(user); + if (!tokenRef.current) return; + setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null }); + } + + async function startPreview(userId: string) { + const transition = ++transitionRef.current; + const next = await fetchPreviewControl<{ previewToken: string; user: AuthUser; expiresAt: string }>('start', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }), + }); + if (transition !== transitionRef.current) return; + invalidateAuthenticatedRequests(); + previewTokenRef.current = next.previewToken; + sessionStorage.setItem('bi_preview_token', next.previewToken); + setPreview({ user: next.user, expiresAt: next.expiresAt }); + setIdentityVersion(v => v + 1); + setPreviewNotice(null); + } + + async function stopPreview() { + const stopping = fetchPreviewControl('stop', { method: 'POST' }, true); + resetPreview(); + await stopping.catch(() => { setPreviewNotice('已退出本地预览;服务端凭证将自动到期。'); }); + } + + useEffect(() => { + if (!preview) return; + const token = previewTokenRef.current; + const validate = async () => { + try { + const current = await fetchPreviewControl<{ user: AuthUser; expiresAt: string }>('current', undefined, true); + if (token !== previewTokenRef.current) return; + if (JSON.stringify(current.user) !== JSON.stringify(preview.user)) { + invalidateAuthenticatedRequests(); + setPreview(current); + setIdentityVersion(v => v + 1); + } + } catch { + if (token === previewTokenRef.current) resetPreview('权限视角验证失败,已返回本人视角,请重新选择。'); + } + }; + const timer = window.setInterval(validate, 60_000); + const expiry = window.setTimeout(() => resetPreview('权限视角已到期,已返回本人视角。'), Math.max(0, Date.parse(preview.expiresAt) - Date.now())); + window.addEventListener('focus', validate); + return () => { clearInterval(timer); clearTimeout(expiry); window.removeEventListener('focus', validate); }; + }, [preview]); useEffect(() => { // 设置全局 token getter setTokenGetter(() => tokenRef.current); - // 防止 StrictMode 双重调用(jumpToken 一次性使用) - if (authStarted.current) return; - authStarted.current = true; + setPreviewTokenGetter(() => previewTokenRef.current); // 监听 401 事件 const onUnauthorized = () => { + resetPreview(); + setCanPreview(false); tokenRef.current = null; sessionStorage.removeItem('bi_jwt'); + sessionStorage.removeItem('bi_user'); + actorRef.current = null; setState({ isLoading: false, isAuthenticated: false, user: null, error: '会话已过期' }); }; window.addEventListener('auth:unauthorized', onUnauthorized); - authenticate(); - - return () => window.removeEventListener('auth:unauthorized', onUnauthorized); + const onPreviewEnded = (event: Event) => { + if ((event as CustomEvent).detail === 'PREVIEW_REVOKED') setCanPreview(false); + resetPreview('预览权限已失效,已返回本人视角。'); + }; + const onReadOnly = () => setPreviewNotice('权限视角仅供查看数据,请退出预览后进行修改操作。'); + window.addEventListener('auth:preview-ended', onPreviewEnded); + window.addEventListener('auth:preview-readonly', onReadOnly); + // StrictMode 重新安装监听,但一次性 jumpToken 仅交换一次。 + if (!authStarted.current) { authStarted.current = true; void authenticate(); } + return () => { + window.removeEventListener('auth:unauthorized', onUnauthorized); + window.removeEventListener('auth:preview-ended', onPreviewEnded); + window.removeEventListener('auth:preview-readonly', onReadOnly); + }; }, []); async function authenticate() { // 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效 if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') { - setState({ - isLoading: false, - isAuthenticated: true, - user: { - userId: 'dev-local', - userName: '本地开发', - permissionLevel: 'full', - depName: '', - roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'], - }, - error: null, - }); + const devUser: AuthUser = { + userId: 'dev-local', + userName: '本地开发', + permissionLevel: 'full', + depName: '', + roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'], + }; + // 预览授权仍由后端独立开发开关决定,不使用真实用户令牌。 + tokenRef.current = null; + await loadPreviewContext(devUser); + actorRef.current ||= devUser; + setState({ isLoading: false, isAuthenticated: true, user: actorRef.current, error: null }); return; } @@ -57,22 +161,20 @@ export default function AuthProvider({ children }: { children: ReactNode }) { const savedToken = sessionStorage.getItem('bi_jwt'); if (savedToken) { tokenRef.current = savedToken; - // 验证 token 是否仍然有效(尝试请求 health) + // 从受保护的身份接口验证 JWT;不使用本地缓存用户决定权限。 try { - const res = await fetch('/api/health', { + const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${savedToken}` }, }); if (res.ok) { - const savedUser = sessionStorage.getItem('bi_user'); - setState({ - isLoading: false, - isAuthenticated: true, - user: savedUser ? JSON.parse(savedUser) : null, - error: null, - }); + const verifiedUser: AuthUser = await res.json(); + if (!verifiedUser.userId) throw new Error('无效的用户身份'); + await finishAuthentication(verifiedUser); return; } } catch { /* token 无效,继续流程 */ } + tokenRef.current = null; + resetPreview(); sessionStorage.removeItem('bi_jwt'); sessionStorage.removeItem('bi_user'); } @@ -108,19 +210,16 @@ export default function AuthProvider({ children }: { children: ReactNode }) { : `${window.location.pathname}${window.location.hash}`; window.history.replaceState({}, '', cleanUrl); - setState({ - isLoading: false, - isAuthenticated: true, - user: data.user, - error: null, - }); + await finishAuthentication(data.user); } catch (e) { setState({ isLoading: false, isAuthenticated: false, user: null, error: '认证过程出错' }); } } return ( - + setPreviewNotice(null) }}> {children} ); diff --git a/src/auth/PermissionPreview.css b/src/auth/PermissionPreview.css new file mode 100644 index 0000000..8f63f22 --- /dev/null +++ b/src/auth/PermissionPreview.css @@ -0,0 +1,31 @@ +.permission-preview-banner{position:fixed;inset:0 0 auto;z-index:200;display:flex;align-items:center;gap:12px;min-height:56px;padding:9px 20px;background:#eff6ff;border-bottom:1px solid #adc8f4;color:#1e40af;font-size:12px;box-shadow:0 2px 8px #1d4ed10a} +.permission-preview-identity{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px}.permission-preview-identity strong{font-size:13px}.permission-preview-identity small{margin-left:14px;color:#64748b} +.permission-preview-banner button{border:1px solid #b9cef1;border-radius:6px;background:#fff;padding:6px 10px;white-space:nowrap}.permission-preview-banner button:hover{background:#dbeafe} +.permission-preview-active{padding-top:56px}.permission-preview-active .app-shell>nav.fixed.top-0{top:56px;height:calc(100% - 56px)}.permission-preview-active .app-shell-content>.fixed.top-0{top:56px} +.permission-preview-notice{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:210;display:flex;align-items:center;gap:14px;padding:10px 14px;border:1px solid #f5d79e;border-radius:8px;background:#fff9ed;color:#92400e;font-size:12px;box-shadow:0 3px 12px #0f172a15;max-width:90vw}.permission-preview-notice button{flex-shrink:0} +.permission-preview-dialog{position:fixed;inset:0;margin:auto;width:min(700px,calc(100vw - 32px));max-height:calc(100dvh - 48px);padding:0;border:1px solid #d7e1ee;border-radius:14px;background:#fff;color:#334155;box-shadow:0 24px 90px #0f172a35;font-size:12px} +.permission-preview-dialog header{display:flex;justify-content:space-between;align-items:flex-start;padding:20px 22px 16px;border-bottom:1px solid #e1e8f0}.permission-preview-dialog h2{display:flex;align-items:center;gap:8px;margin:0;font-size:16px;font-weight:700;color:#1e293b}.permission-preview-dialog header p{margin:6px 0 0;color:#64748b}.permission-preview-dialog header button{padding:3px;color:#64748b} +.permission-preview-body{padding:18px 22px;overflow:auto;min-height:0}.permission-preview-guide{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.permission-preview-guide>div{padding:12px;border:1px solid #dce6f3;border-radius:8px;background:#f8fafc}.permission-preview-guide strong{display:block;color:#1e40af;font-size:12px}.permission-preview-guide code{display:block;margin-top:5px;font:11px ui-monospace,monospace;color:#64748b}.permission-preview-guide p{margin:7px 0 0;line-height:1.6;color:#64748b}.permission-preview-hint{margin:12px 0 16px;color:#64748b;font-size:11px;line-height:1.7} +.permission-preview-search{display:flex;gap:10px;margin-bottom:12px}.permission-preview-search label{display:flex;align-items:center;gap:8px;flex:1;border:1px solid #ced9e8;border-radius:7px;padding:0 10px;color:#94a3b8}.permission-preview-search input{width:100%;min-width:0;border:0;outline:0;padding:10px 0;color:#334155;background:transparent}.permission-preview-search label:focus-within{outline:2px solid #93b5ff;outline-offset:1px}.permission-preview-search select{border:1px solid #ced9e8;border-radius:7px;background:white;padding:8px;color:#475569} +.permission-preview-users{min-height:140px;border:1px solid #dce4ef;border-radius:8px;overflow:hidden}.permission-preview-users>label{display:flex;align-items:center;gap:10px;padding:11px 13px;border-bottom:1px solid #e5ebf3;cursor:pointer}.permission-preview-users>label:last-child{border-bottom:0}.permission-preview-users>label:hover{background:#f8faff}.permission-preview-users>label.is-selected{background:#eff6ff}.permission-preview-users input{accent-color:#2563eb}.permission-preview-users label>div{min-width:0;flex:1}.permission-preview-users strong{display:block;font-size:13px;color:#334155}.permission-preview-users span{display:block;font-size:11px;color:#64748b;margin-top:3px;overflow-wrap:anywhere}.permission-preview-users small{font-weight:400;font-size:10px;color:#2563eb}.permission-preview-users em{font-style:normal;padding:4px 7px;border-radius:5px;background:#f1f5f9;color:#475569;white-space:nowrap;font-size:11px}.permission-preview-empty{padding:35px 10px;text-align:center;color:#94a3b8} +.permission-preview-pages{display:flex;align-items:center;justify-content:flex-end;gap:12px;margin-top:12px;color:#64748b}.permission-preview-pages button{border:1px solid #dce4ef;padding:5px 9px;border-radius:5px}.permission-preview-dialog button:disabled{opacity:.45;cursor:not-allowed}.permission-preview-error{padding:9px 11px;margin:10px 0;background:#fff1f2;color:#be123c;border-radius:6px;display:flex;justify-content:space-between;gap:10px}.permission-preview-error button{text-decoration:underline} +.permission-preview-dialog footer{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 22px;border-top:1px solid #dce4ef;background:#f8fafc}.permission-preview-dialog footer strong{display:block;color:#334155;font-size:12px}.permission-preview-dialog footer span{display:block;color:#64748b;font-size:11px;margin-top:3px}.permission-preview-primary{padding:9px 13px;border-radius:7px;background:#2563eb;color:#fff;white-space:nowrap}.permission-preview-dialog button:focus-visible,.permission-preview-banner button:focus-visible{outline:2px solid #2563eb;outline-offset:3px} +@media(max-width:640px){.permission-preview-banner{gap:7px;padding:8px 10px;min-height:76px}.permission-preview-identity{display:block}.permission-preview-identity>span{display:block;margin-top:4px;font-size:10px}.permission-preview-identity small{display:block;margin:2px 0 0}.permission-preview-banner button{padding:6px;font-size:11px}.permission-preview-active{padding-top:76px}.permission-preview-notice{top:84px}.permission-preview-dialog{width:calc(100vw - 16px);max-height:calc(100dvh - 20px)}.permission-preview-body{padding:14px}.permission-preview-dialog header,.permission-preview-dialog footer{padding:14px}.permission-preview-guide{gap:6px}.permission-preview-guide>div{padding:8px}.permission-preview-guide code{font-size:9px}.permission-preview-guide p{font-size:10px}.permission-preview-search{gap:6px}.permission-preview-search select{max-width:116px}} + +.permission-preview-active .asset-analysis-overlay,.permission-preview-active .bi-flow-modal-backdrop{top:56px} +@media(max-width:640px){.permission-preview-active .asset-analysis-overlay,.permission-preview-active .bi-flow-modal-backdrop{top:76px}} +.permission-preview-active .asset-analysis-dialog{min-height:0;max-height:calc(100dvh - 104px)} +.permission-preview-active .bi-flow-modal{max-height:calc(100dvh - 88px)} +@media(max-width:640px){.permission-preview-active .asset-analysis-dialog{max-height:calc(100dvh - 124px)}.permission-preview-active .bi-flow-modal{max-height:calc(100dvh - 108px)}} +.permission-preview-page{min-height:100dvh;padding:32px 20px;background:#f4f7fb;display:flex;align-items:flex-start;justify-content:center} +.permission-preview-active .permission-preview-page{min-height:calc(100dvh - 56px)} +.permission-preview-page .permission-preview-page-panel{position:static;inset:auto;display:flex;flex-direction:column;width:min(820px,100%);max-height:none;margin:0;border-color:#dce5f0;box-shadow:0 10px 35px #33415508} +.permission-preview-page-panel .permission-preview-body{overflow:visible} +.permission-preview-dialog header .permission-preview-back{display:inline-flex;align-items:center;justify-content:center;gap:7px;flex-shrink:0;min-height:44px;padding:10px 16px;border:1px solid #ced9e8;border-radius:8px;background:#f8fafc;font-size:14px;font-weight:600;color:#475569;white-space:nowrap;cursor:pointer;transition:background .15s,border-color .15s}.permission-preview-dialog header .permission-preview-back:hover:not(:disabled){background:#eff6ff;border-color:#adc8f4;color:#1d4ed8} +.permission-preview-denied{width:min(500px,100%);padding:36px;background:white;border:1px solid #dce5f0;border-radius:12px;text-align:center;color:#64748b}.permission-preview-denied>svg{margin:auto;color:#94a3b8}.permission-preview-denied h1{font-size:18px;color:#334155;margin:16px 0 8px}.permission-preview-denied p{font-size:13px}.permission-preview-denied a{display:inline-block;margin-top:20px;font-size:12px;color:#2563eb} +@media(max-width:640px){.permission-preview-page{padding:16px 10px}.permission-preview-active .permission-preview-page{min-height:calc(100dvh - 76px)}} +.permission-preview-page .permission-preview-page-panel{max-height:calc(100dvh - 64px)} +.permission-preview-page-panel .permission-preview-body{overflow:auto} +.permission-preview-page-panel>header,.permission-preview-page-panel>footer{flex-shrink:0} +.permission-preview-active .permission-preview-page-panel{max-height:calc(100dvh - 120px)} +@media(max-width:640px){.permission-preview-page .permission-preview-page-panel{max-height:calc(100dvh - 32px)}.permission-preview-active .permission-preview-page-panel{max-height:calc(100dvh - 108px)}} diff --git a/src/auth/PermissionPreview.tsx b/src/auth/PermissionPreview.tsx new file mode 100644 index 0000000..9d1144a --- /dev/null +++ b/src/auth/PermissionPreview.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { ArrowLeft, Eye, Search, ShieldCheck, X } from 'lucide-react'; +import { fetchPreviewControl } from './api-client'; +import { useAuth, type AuthUser } from './useAuth'; +import './PermissionPreview.css'; + +const scopes = [ + { level: 'full', title: '全量数据', role: 'BI-Leader', detail: '查看全量业务数据。' }, + { level: 'department', title: '部门数据', role: 'BI-Leader-Dep', detail: '查看当前用户所在部门的数据。' }, + { level: 'personal', title: '本人数据', role: '其他角色默认', detail: '查看本人负责的车辆。' }, +]; +export function permissionScope(user: AuthUser) { + return scopes.find(s => s.level === user.permissionLevel)?.title || '本人数据'; +} +export default function PermissionPreview({ children }: { children: ReactNode }) { + const { isAuthenticated, actor, preview, previewNotice, clearPreviewNotice, stopPreview } = useAuth(); + return
+ {isAuthenticated && preview && } + {previewNotice &&
{previewNotice}
} + {children} +
; +} +export function PermissionPreviewPage() { + const { canPreview } = useAuth(); + return
+ {canPreview ? window.location.assign('/asset#assets')} /> :

无权限访问

当前账号未获授权使用权限视角。

返回 BI
} +
; +} +function PreviewPicker({ onClose }: { onClose: () => void }) { + const { startPreview, preview } = useAuth(); + const [query, setQuery] = useState(''); + const [level, setLevel] = useState(''); + const [page, setPage] = useState(1); + const [users, setUsers] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [retry, setRetry] = useState(0); + const hasQuery = query.trim().length > 0; + useEffect(() => { + const controller = new AbortController(); + setError(''); setUsers([]); setSelected(null); setHasMore(false); + if (!query.trim()) { setLoading(false); return; } + setLoading(true); + const timer = window.setTimeout(async () => { + try { + const params = new URLSearchParams({ q: query.trim(), page: String(page) }); + if (level) params.set('level', level); + const result = await fetchPreviewControl<{ users: AuthUser[]; page: number; hasMore: boolean }>(`users?${params}`, { signal: controller.signal }); + if (!controller.signal.aborted) { setUsers(result.users); setHasMore(result.hasMore); } + } catch (e) { + if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); + } finally { if (!controller.signal.aborted) setLoading(false); } + }, 250); + return () => { clearTimeout(timer); controller.abort(); }; + }, [query, level, page, retry]); + async function enter() { + if (!selected || busy) return; + setBusy(true); setError(''); + try { await startPreview?.(selected.userId); onClose(); } + catch (e) { setError(e instanceof Error ? e.message : '切换失败,请重试'); setBusy(false); } + } + return
+

权限视角

选择人员,查看其实际页面权限与数据范围。

+
+
{scopes.map(scope =>
{scope.title}{scope.role}

{scope.detail}

)}
+

菜单和数据随目标用户权限变化;共享汇总仍按页面注明的公共口径展示。预览期间仅可查看,不能修改业务数据。

+
+ {error &&
{error}
} + {!hasQuery ?

输入中文姓名、用户名或部门关键词,模糊搜索人员。

:
+ {loading ?

正在加载人员…

: !users.length ?

未找到匹配人员,请调整搜索条件。

: users.map(user => )} +
} + {hasQuery && (hasMore || page > 1) &&
第 {page} 页
} +
+
{selected ? <>即将进入:{selected.userName}{permissionScope(selected)} : 请选择一位用户开始预览}
+
; +} diff --git a/src/auth/api-client.ts b/src/auth/api-client.ts index 4cee583..fcfe4fe 100644 --- a/src/auth/api-client.ts +++ b/src/auth/api-client.ts @@ -1,27 +1,59 @@ -/** 全局认证 fetch 客户端 */ - +/** 登录身份和预览身份分别传递;切换视角会取消旧视角的在途请求。 */ let tokenGetter: () => string | null = () => null; - -export function setTokenGetter(fn: () => string | null) { - tokenGetter = fn; +let previewTokenGetter: () => string | null = () => null; +let identityAbort = new AbortController(); +let identityVersion = 0; +export function setTokenGetter(fn: () => string | null) { tokenGetter = fn; } +export function setPreviewTokenGetter(fn: () => string | null) { previewTokenGetter = fn; } +export function invalidateAuthenticatedRequests() { + identityVersion += 1; + identityAbort.abort(); + identityAbort = new AbortController(); } +const expiredPreviewCodes = new Set(['PREVIEW_EXPIRED', 'PREVIEW_REVOKED', 'PREVIEW_INVALID', 'PREVIEW_TARGET_UNAVAILABLE']); -export async function fetchAuthenticated(url: string, options?: RequestInit): Promise { +async function authenticatedRequest(url: string, options: RequestInit | undefined, preview: boolean, business: boolean): Promise { + const version = identityVersion; + const headers = new Headers(options?.headers); const token = tokenGetter(); - const res = await fetch(url, { - ...options, - headers: { - ...options?.headers, - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); - if (res.status === 401) { - window.dispatchEvent(new CustomEvent('auth:unauthorized')); - throw new Error('Unauthorized'); + if (token) headers.set('Authorization', `Bearer ${token}`); + headers.delete('X-BI-Preview'); + const previewToken = preview ? previewTokenGetter() : null; + if (previewToken) headers.set('X-BI-Preview', previewToken); + const signal = business + ? options?.signal ? AbortSignal.any([options.signal, identityAbort.signal]) : identityAbort.signal + : options?.signal; + const res = await fetch(url, { ...options, headers, signal }); + if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError'); + if (res.status === 401 || res.status === 403 || res.status === 410) { + const body = await res.clone().json().catch(() => ({})); + if (business && version !== identityVersion) throw new DOMException('权限视角已切换', 'AbortError'); + if (expiredPreviewCodes.has(body.code)) { + // 已切换到其他人的旧响应不能结束新预览。 + if (previewToken && previewToken === previewTokenGetter()) window.dispatchEvent(new CustomEvent('auth:preview-ended', { detail: body.code })); + throw new Error('权限视角已失效,请重新选择用户'); + } + if (res.status === 401) { + window.dispatchEvent(new CustomEvent('auth:unauthorized')); + throw new Error('Unauthorized'); + } + if (body.code === 'PREVIEW_READ_ONLY') { + window.dispatchEvent(new CustomEvent('auth:preview-readonly')); + throw new Error('权限视角为只读,请退出预览后操作'); + } } return res; } - +export function fetchAuthenticated(url: string, options?: RequestInit): Promise { + return authenticatedRequest(url, options, true, true); +} +/** 管理接口始终使用实际登录身份;仅 current/stop 附带当前预览凭证。 */ +export async function fetchPreviewControl(path: string, options?: RequestInit, includePreview = false): Promise { + const res = await authenticatedRequest(`/api/permission-preview/${path}`, options, includePreview, false); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || data.message || '权限视角请求失败,请重试'); + return data as T; +} export async function fetchJson(url: string, options?: RequestInit): Promise { const res = await fetchAuthenticated(url, options); if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts index c869e3a..a032ba3 100644 --- a/src/auth/useAuth.ts +++ b/src/auth/useAuth.ts @@ -1,25 +1,27 @@ import { createContext, useContext } from 'react'; +export interface AuthUser { + userId: string; + userName: string; + loginName?: string; + permissionLevel: string; + depCode?: string; + depName: string; + roles?: string[]; +} export interface AuthState { isLoading: boolean; isAuthenticated: boolean; - user: { - userId: string; - userName: string; - permissionLevel: string; - depName: string; - roles?: string[]; - } | null; + user: AuthUser | null; error: string | null; + actor?: AuthUser | null; + canPreview?: boolean; + preview?: { user: AuthUser; expiresAt: string } | null; + identityVersion?: number; + previewNotice?: string | null; + startPreview?: (userId: string) => Promise; + stopPreview?: () => Promise; + clearPreviewNotice?: () => void; } - -export const AuthContext = createContext({ - isLoading: true, - isAuthenticated: false, - user: null, - error: null, -}); - -export function useAuth() { - return useContext(AuthContext); -} +export const AuthContext = createContext({ isLoading: true, isAuthenticated: false, user: null, error: null }); +export function useAuth() { return useContext(AuthContext); } diff --git a/src/components/FeedbackFab.tsx b/src/components/FeedbackFab.tsx index a527733..daa2727 100644 --- a/src/components/FeedbackFab.tsx +++ b/src/components/FeedbackFab.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { fetchJson } from '../auth/api-client'; +import { fetchJson, fetchAuthenticated } from '../auth/api-client'; import { useAuth } from '../auth/useAuth'; import { canManageFeedback } from '../shared/auth/roles'; import FeedbackHistoryDrawer from './FeedbackHistoryDrawer'; @@ -13,10 +13,8 @@ const MAX_IMG_SIZE_MB = 5; async function uploadImage(file: File): Promise { const fd = new FormData(); fd.append('file', file); - const token = sessionStorage.getItem('bi_jwt'); - const res = await fetch('/api/feedback/upload', { + const res = await fetchAuthenticated('/api/feedback/upload', { method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd, }); const json = await res.json(); diff --git a/src/modules/admin/FeedbackAdminPage.tsx b/src/modules/admin/FeedbackAdminPage.tsx index 28b7b65..d7e0dce 100644 --- a/src/modules/admin/FeedbackAdminPage.tsx +++ b/src/modules/admin/FeedbackAdminPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2, ArrowLeft } from 'lucide-react'; -import { fetchJson } from '../../auth/api-client'; +import { fetchJson, fetchAuthenticated } from '../../auth/api-client'; import { EmptyState, LoadingState, PageFrame, SurfaceCard } from '../../components/ui/surface'; interface FeedbackItem { @@ -49,10 +49,9 @@ function parseScreenshots(s: FeedbackItem['screenshots']): string[] { } async function patchItem(id: number, data: { status?: string; reply?: string }): Promise { - const token = sessionStorage.getItem('bi_jwt'); - const res = await fetch(`/api/feedback/${id}`, { + const res = await fetchAuthenticated(`/api/feedback/${id}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const json = await res.json(); diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 30c1bae..0f81070 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -24,16 +24,14 @@ import { Cell, LabelList, } from 'recharts'; -import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types'; -import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api'; -import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api'; +import type { SummaryData, TypeSummary, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types'; +import { fetchSummary, fetchByType, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api'; +import type { FlowDetailItem, FlowStatsResponse, FlowType } from './api'; import { buildCustomerPieData, - buildVehicleModalRequest, deriveCustomerView, deriveDepartmentView, deriveInventoryView, - deriveModalVehicleView, formatLocalDateTime, getRecentFlowRange, selectFlowDetails, @@ -45,8 +43,8 @@ import { MultiSearchSelect } from '../../components/MultiSearchSelect'; import Blur from '../../components/Blur'; import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface'; import { AssetsHeader } from './components/AssetsHeader'; -import { FlowDetailModal } from './components/FlowDetailModal'; -import { VehicleDetailModal } from './components/VehicleDetailModal'; +import { FlowAnalysisModal } from './components/FlowAnalysisModal'; +import { AssetAnalysisModal } from './components/analysis/AssetAnalysisModal'; import { OverviewView } from './components/OverviewView'; @@ -68,7 +66,7 @@ export default function AssetsModule() { useEffect(() => { if (!assistantOpen) return; const previousTitle = document.title; - document.title = 'AI 问数 - 资产BI'; + document.title = 'AI问数 - 资产BI'; return () => { document.title = previousTitle; }; }, [assistantOpen]); const openAssistant = () => { @@ -110,16 +108,14 @@ export default function AssetsModule() { const [expandedModels, setExpandedModels] = useState>(new Set()); const [expandedAssetTypes, setExpandedAssetTypes] = useState>(new Set()); const [showPlateNumbers, setShowPlateNumbers] = useState(null); + const closeAssetAnalysis = useCallback(() => setShowPlateNumbers(null), []); // Data state const [summary, setSummary] = useState(null); const [processedData, setProcessedData] = useState([]); - const [modalVehicles, setModalVehicles] = useState([]); - const [modalWeeklyDetail, setModalWeeklyDetail] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [lastUpdate, setLastUpdate] = useState(() => formatLocalDateTime(new Date())); - const [modalLoading, setModalLoading] = useState(false); const [flowRange, setFlowRange] = useState(() => getRecentFlowRange()); const [flowStats, setFlowStats] = useState(null); const [flowLoading, setFlowLoading] = useState(false); @@ -164,17 +160,6 @@ export default function AssetsModule() { const [regionChartView, setRegionChartView] = useState<'region' | 'province'>('region'); const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]); - // Modal filter state - const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' }); - const [isModalFilterExpanded, setIsModalFilterExpanded] = useState(false); - - // Reset modal filters when modal opens - useEffect(() => { - if (showPlateNumbers) { - setModalFilters({ plateNumber: '', model: '', brand: '', location: '' }); - } - }, [showPlateNumbers]); - const loadData = useCallback(async () => { try { setLoading(true); @@ -263,30 +248,7 @@ export default function AssetsModule() { .catch(() => setRegionChartData([])); }, [regionChartView, selectedSubject]); - // Load modal vehicles - useEffect(() => { - if (!showPlateNumbers) { - setModalVehicles([]); - setModalWeeklyDetail([]); - return; - } - setModalLoading(true); - const request = buildVehicleModalRequest(showPlateNumbers, selectedSubject); - if (request.kind === 'weekly') { - setModalVehicles([]); - fetchWeeklyDetail(request.type, { ...request.filters, subject: selectedSubject }) - .then(setModalWeeklyDetail) - .catch(() => setModalWeeklyDetail([])) - .finally(() => setModalLoading(false)); - return; - } - - setModalWeeklyDetail([]); - fetchVehicleList(request.params) - .then(setModalVehicles) - .catch(() => setModalVehicles([])) - .finally(() => setModalLoading(false)); - }, [showPlateNumbers, selectedSubject]); + // Vehicle records are requested only at the final level of AssetAnalysisModal. const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type)); @@ -404,18 +366,6 @@ export default function AssetsModule() { [customerData, deptData, customerFilters], ); - const { - plates: uniqueModalPlates, - models: uniqueModalModels, - brands: uniqueModalBrands, - locations: uniqueModalLocations, - filteredVehicles: filteredModalVehicles, - filteredWeeklyDetails: filteredModalWeeklyDetail, - } = useMemo( - () => deriveModalVehicleView(modalVehicles, modalWeeklyDetail, modalFilters), - [modalVehicles, modalWeeklyDetail, modalFilters], - ); - const selectedFlowDetails = useMemo( () => selectFlowDetails(flowStats, selectedFlow), [flowStats, selectedFlow], @@ -464,7 +414,7 @@ export default function AssetsModule() { if (assistantOpen) { return ( - 正在加载 AI 问数页面…}> + 正在加载AI问数页面…}> ); @@ -1448,7 +1398,7 @@ export default function AssetsModule() {
{ setMergeParents(event.target.checked); }} />合并上级维度 : null} + {showDetails ? : null} +
+ + {!showDetails ? groups.length ? <> +
{dimensions.length ? dimensions.map(dimension => ) : } + {shownGroups.map((group, rowIndex) => 0 ? 'bi-flow-modal-group-start' : undefined}> + {dimensions.length ? group.values.map((value, index) => { + const span = mergeActive ? groupSpans[rowIndex][index] : 1; + return span === 0 ? null : ; + }) : } + + + )} +
{DIMENSION_LABELS[dimension]}范围车次占比操作
1 ? span : undefined} className={span > 1 ? 'bi-flow-modal-parent-cell' : undefined}>{dimensionLabel(dimensions[index], value)}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <>合计 {countLabel(parentTotals[rowIndex][index])} 车次{DIMENSION_LABELS[dimensions[index + 1]]}:{countLabel(childCounts[rowIndex][index])} {dimensionCountUnit(dimensions[index + 1])}}当前筛选范围{countLabel(group.rows.length)}
{(group.rows.length / filteredRows.length * 100).toFixed(1)}%
+ + :
当前筛选范围暂无交还车数据,请调整筛选。
+ : detailRows.length ? <> +
{shownDetails.map(item => )}
车牌业务类型运维签章时间建单时间部门业务负责人客户
{item.plateNumber || '未登记车牌'}{FLOW_META[item.type].label}{item.eventTime || '—'}{item.submitTime || '—'}{dimensionLabel('department', dimensionValue(item, 'department'))}{item.manager || '—'}{item.customerName || '—'}
+ + :
当前分组暂无业务明细,请返回统计调整条件。
} + + + ; +} diff --git a/src/modules/assets/components/FlowDetailModal.tsx b/src/modules/assets/components/FlowDetailModal.tsx index 5935a00..e191f02 100644 --- a/src/modules/assets/components/FlowDetailModal.tsx +++ b/src/modules/assets/components/FlowDetailModal.tsx @@ -46,7 +46,7 @@ export function FlowDetailModal({ {dateLabel} · {FLOW_META[selectedFlow.type].label}明细
- 共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、负责人和客户 + 共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、业务负责人和客户
+
+ + {available.map(dimension => { + const index = dimensions.indexOf(dimension); + const selected = filters[dimension] || []; + const filterLabel = selected.map(value => getDimensionValueLabel(dimension, value)).join('、'); + return
+ + +
; + })} +
+ {activeCount > 0 &&
{activeCount} 个维度已筛选 · 同维度多选为“或”,不同维度为“且”
} + {editor &&
{ if (event.key === 'Escape') { event.stopPropagation(); closeEditor(); } }}> +
{DIMENSION_LABELS[editor]}搜索与筛选
+ +
已选 {draft.length} 项
+
+ {options.map(option => )} + {!options.length &&

没有匹配选项,可调整搜索或其他筛选。

} +
+
未选择时不限制此维度
+
} + ; +} diff --git a/src/modules/assets/components/analysis/AssetAnalysisModal.tsx b/src/modules/assets/components/analysis/AssetAnalysisModal.tsx new file mode 100644 index 0000000..3ba4b34 --- /dev/null +++ b/src/modules/assets/components/analysis/AssetAnalysisModal.tsx @@ -0,0 +1,270 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowLeft, BarChart3, Filter, Loader2, Search, X } from 'lucide-react'; +import { fetchJson } from '../../../../auth/api-client'; +import Blur from '../../../../components/Blur'; +import type { AssetAnalysisResponse, AssetAnalysisDimension } from '../../../../shared/assets/analytics'; +import { ANALYSIS_META, DIMENSION_LABELS, compareCustomLabels, getCustomAnalysis, getAvailableDimensions, getDefaultDimensions, getDimensionValueLabel, type AnalysisFilterValues, type CustomAnalysisRow, type AnalysisSort, type AnalysisView } from '../../analytics-model'; +import { dimensionChildCounts, dimensionCountUnit, dimensionParentTotals, dimensionRowSpans, orderDimensionGroups, visibleDimensionRowSpans } from '../../dimension-layout'; +import { AnalysisControls } from './AnalysisControls'; +import { DimensionPicker } from './DimensionPicker'; +import { ProgressiveRowsStatus } from './ProgressiveRowsStatus'; +import { useProgressiveRows } from './useProgressiveRows'; +import { buildVehicleModalRequest, formatLocalDateTime, type VehicleModalSelection } from '../../model'; +import { fetchWeeklyDetail, type WeeklyDetailItem } from '../../api'; +import type { VehicleListItem } from '../../types'; +import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails'; +import { useDialogFocus } from '../useDialogFocus'; +import './asset-analysis.css'; + +type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void }; +const number = (value: number) => value.toLocaleString('zh-CN'); +function initialView(selection: VehicleModalSelection): AnalysisView { + if (selection.category === 'Abnormal') return 'abnormal'; + if (selection.category === 'Inventory') return 'inventory'; + if (selection.category === 'Pending') return 'pending'; + if (selection.source === 'department') return 'department'; + if (selection.source === 'customer') return 'customer'; + if (selection.category === 'Operating') return 'operating'; + return 'assets'; +} + +export function AssetAnalysisModal({ selection, subject, close }: Props) { + if (!selection) return null; + return ; +} + +function AnalysisDialog({ selection, subject, close }: Props & { selection: VehicleModalSelection }) { + const dialog = useRef(null); + const scrollRoot = useRef(null); + useDialogFocus(true, dialog, close); + const [data, setData] = useState(null); + const [weekly, setWeekly] = useState([]); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(''); + const [retry, setRetry] = useState(0); + const view = initialView(selection); + const [dimensions, setDimensions] = useState(() => getDefaultDimensions(view)); + const [filterValues, setFilterValues] = useState({}); + const [sort, setSort] = useState('count-desc'); + const [mergeParents, setMergeParents] = useState(true); + const [detailGroup, setDetailGroup] = useState(null); + const showDetails = detailGroup !== null; + const [vehicles, setVehicles] = useState([]); + const [detailBusy, setDetailBusy] = useState(false); + const [detailError, setDetailError] = useState(''); + const [detailRetry, setDetailRetry] = useState(0); + const [search, setSearch] = useState(''); + const request = useMemo(() => buildVehicleModalRequest(selection, subject), [selection, subject]); + const endpoint = `/api/vehicles/analysis${subject ? `?subject=${encodeURIComponent(subject)}` : ''}`; + const available = getAvailableDimensions(view); + const analysis = useMemo(() => getCustomAnalysis(data?.cells ?? [], { view, dimensions, filterValues, sort }), [data, view, dimensions, filterValues, sort]); + const filterValuesKey = JSON.stringify(filterValues); + const detailFiltersKey = JSON.stringify(detailGroup?.values ?? {}); + + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + setBusy(true); setError(''); + const load = request.kind === 'weekly' + ? fetchWeeklyDetail(request.type, { ...request.filters, subject }).then(value => { if (!cancelled) setWeekly(value); }) + : fetchJson(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: request.params }), signal: controller.signal }).then(value => { if (!cancelled) setData(value); }); + load.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : '统计加载失败'); }) + .finally(() => { if (!cancelled) setBusy(false); }); + return () => { cancelled = true; controller.abort(); }; + }, [request, subject, endpoint, retry]); + + useEffect(() => { + if (!showDetails || request.kind !== 'vehicles' || !data) return; + const controller = new AbortController(); + let cancelled = false; + setDetailBusy(true); setDetailError(''); setVehicles([]); setSearch(''); + fetchJson(endpoint.replace('/analysis', '/analysis/details'), { + method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, + body: JSON.stringify({ metric: analysis.metric, filters: JSON.parse(detailFiltersKey), filterValues: JSON.parse(filterValuesKey), generatedAt: data.generatedAt, scope: request.params }), + }).then(value => { if (!cancelled) setVehicles(value); }) + .catch(e => { if (!cancelled) setDetailError(e instanceof Error ? e.message : '车辆明细加载失败'); }) + .finally(() => { if (!cancelled) setDetailBusy(false); }); + return () => { cancelled = true; controller.abort(); }; + }, [showDetails, analysis.metric, detailFiltersKey, filterValuesKey, request, data, endpoint, detailRetry]); + + const visibleVehicles = useMemo(() => { + const query = search.toLocaleLowerCase(); + return vehicles.filter(vehicle => !query || [vehicle.plateNumber, vehicle.vin, vehicle.model, vehicle.city].some(value => value?.toLocaleLowerCase().includes(query))); + }, [vehicles, search]); + const detailProgress = useProgressiveRows({ total: visibleVehicles.length, rootRef: scrollRoot, resetKey: visibleVehicles, enabled: showDetails && !detailBusy && !busy, batchSize: 40 }); + const shownVehicles = useMemo(() => visibleVehicles.slice(0, detailProgress.visibleCount), [visibleVehicles, detailProgress.visibleCount]); + const updateDimensions = (next: AssetAnalysisDimension[]) => { setDimensions(next); }; + const updateFilters = (next: AnalysisFilterValues) => { setFilterValues(next); }; + const reset = () => { updateDimensions(getDefaultDimensions(view)); updateFilters({}); setMergeParents(true); }; + const narrowToGroup = (row: CustomAnalysisRow) => { + updateFilters({ ...filterValues, ...Object.fromEntries(Object.entries(row.values).map(([dimension, value]) => [dimension, [value]])) }); + }; + const merged = mergeParents && analysis.dimensions.length > 1; + const orderedGroups = useMemo(() => merged + ? orderDimensionGroups(analysis.rows, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || ''), row => row.count, sort, (a, b, index) => compareCustomLabels(a, b, [analysis.dimensions[index]])) + : analysis.rows, [analysis, merged, sort]); + const parentTotals = useMemo(() => merged ? dimensionParentTotals(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || ''), row => row.count) : [], [orderedGroups, analysis.dimensions, merged]); + const childCounts = useMemo(() => merged ? dimensionChildCounts(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [orderedGroups, analysis.dimensions, merged]); + const groupProgress = useProgressiveRows({ total: orderedGroups.length, rootRef: scrollRoot, resetKey: orderedGroups, enabled: !showDetails && !busy, batchSize: 40 }); + const shownGroups = useMemo(() => orderedGroups.slice(0, groupProgress.visibleCount), [orderedGroups, groupProgress.visibleCount]); + const completeSpans = useMemo(() => merged ? dimensionRowSpans(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [merged, orderedGroups, analysis.dimensions]); + const spans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]); + const title = request.kind === 'weekly' ? '交还车统计分析' : ANALYSIS_META[view].title; + const scopeLabel = selection.scopeLabel || [subject || '全部公司', selection.title].filter(Boolean).join(' · '); + return
+
+

{title}

{scopeLabel}

+ {busy ?
正在汇总统计数据…
: error ?

统计数据加载失败:{error}

: request.kind === 'weekly' ? : <> + {showDetails &&
{detailGroup.label}
} +
+ {!showDetails && <> + +
当前结果 {number(analysis.total)} 台{analysis.rows.length} 个分组{analysis.dimensions.length ? analysis.dimensions.map(dimension => DIMENSION_LABELS[dimension]).join(' × ') : '仅汇总,不分组'}
+ {view === 'inventory' &&
待交车 {number(analysis.summary.pending)} 台异动 {number(analysis.summary.abnormal)} 台已包含在当前库存数量中
} + {view === 'abnormal' &&

已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}

} +

{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}

{analysis.dimensions.length > 1 && }
+ {analysis.total === 0 ?

没有符合条件的数据

移除部分筛选条件,或恢复当前入口的全部数据。

{Object.keys(filterValues).length > 0 && }
: <> +
+ {analysis.dimensions.length ? analysis.dimensions.map(dimension => ) : }{view === 'inventory' && <>} + {shownGroups.map((row, rowIndex) => 0 ? 'analysis-group-start' : undefined}> + {analysis.dimensions.length ? analysis.dimensions.map((dimension, columnIndex) => { + const span = merged ? spans[rowIndex][columnIndex] : 1; + return span === 0 ? null : ; + }) : } + + {view === 'inventory' && <>} + + + )} +
{DIMENSION_LABELS[dimension]}统计范围数量(台)待交车异动占比分析操作
1 ? 'analysis-parent-cell' : undefined} data-label={DIMENSION_LABELS[dimension]}>{getDimensionValueLabel(dimension, row.values[dimension] || '')}{merged && columnIndex < analysis.dimensions.length - 1 && childCounts[rowIndex][columnIndex] > 1 && <>合计 {number(parentTotals[rowIndex][columnIndex])} 台{DIMENSION_LABELS[analysis.dimensions[columnIndex + 1]]}:{number(childCounts[rowIndex][columnIndex])} {dimensionCountUnit(analysis.dimensions[columnIndex + 1])}}当前筛选结果{number(row.count)}{number(row.totals.pending)}{number(row.totals.abnormal)}{row.share.toFixed(1)}%{analysis.dimensions.length > 0 && }
+
合计 {number(analysis.total)} 台{view === 'inventory' && ` · 待交车 ${number(analysis.summary.pending)} 台 · 异动 ${number(analysis.summary.abnormal)} 台`} · 占比基于当前筛选结果100%
+ + } + } + {showDetails &&
+

车辆明细 {number(visibleVehicles.length)} 台

+ {detailBusy ?
正在加载车辆明细…
: detailError ?

{detailError}

: <> + {vehicles.length !== detailGroup.count &&

车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。

} + {(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? : } + + } +
} +
+
统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}
+ } +
+
; +} + +function VehicleRows({ vehicles }: { vehicles: VehicleListItem[] }) { + if (!vehicles.length) return
暂无匹配车辆
; + const stateLabel = (vehicle: VehicleListItem) => { + const operation = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record)[vehicle.operationStatus || ''] || vehicle.operationStatus; + const business = ({ Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' } as Record)[vehicle.status] || vehicle.status; + return [business, operation].filter(Boolean).join(' · '); + }; + return
{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', '客户 / 签约公司', '部门 / 业务负责人', '所属公司'].map(label => )}{vehicles.map(vehicle => )}
{label}
{vehicle.plateNumber || vehicle.vin || '未提供'}{vehicle.brandLabel || '—'} · {vehicle.type}{vehicle.model}{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}{stateLabel(vehicle)}{vehicle.customerName || '—'}{vehicle.orgName || '—'}{vehicle.departmentName || '—'}{vehicle.customerManager || '—'}{vehicle.subjectOrg || '—'}
; +} + +type WeeklyDimension = 'date' | 'contract_type'; +const WEEKLY_DIMENSIONS: WeeklyDimension[] = ['date', 'contract_type']; +const EMPTY_WEEKLY_ROWS: WeeklyDetailItem[] = []; +const weeklyDimensionValue = (row: WeeklyDetailItem, dimension: WeeklyDimension) => dimension === 'date' + ? row.handover_date?.slice(0, 10) || '' : row.contract_type?.trim() || ''; + +function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) { + type Dimension = WeeklyDimension; + const dimensionsAvailable = WEEKLY_DIMENSIONS; + const scrollRoot = useRef(null); + const labels: Record = { date: '交还日期', contract_type: '合同类型' }; + const [dimensions, setDimensions] = useState(['date']); + const [mergeParents, setMergeParents] = useState(true); + const mergeActive = mergeParents && dimensions.length > 1; + const [filters, setFilters] = useState>>({}); + const [detailKey, setDetailKey] = useState(null); + const value = weeklyDimensionValue; + const label = (dimension: Dimension, entry: string) => entry || (dimension === 'date' ? '日期缺失' : '未标注'); + const filtered = useMemo(() => rows.filter(row => dimensionsAvailable.every(dimension => !filters[dimension]?.length || filters[dimension]!.includes(value(row, dimension)))), [rows, filters]); + const grouped = useMemo(() => { + const result = new Map(); + for (const row of filtered) { + const values = dimensions.map(dimension => value(row, dimension)); + const key = JSON.stringify(values); + const group = result.get(key) ?? { key, values, rows: [] }; + group.rows.push(row); + result.set(key, group); + } + return result; + }, [filtered, dimensions]); + const groups = useMemo(() => { + const flatGroups = [...grouped.values()].sort((a, b) => b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN')); + return mergeActive ? orderDimensionGroups(flatGroups, dimensions.length, group => group.values, group => group.rows.length) : flatGroups; + }, [grouped, mergeActive, dimensions.length]); + const detailGroup = detailKey === null ? null : grouped.get(detailKey); + const detailRows = detailGroup?.rows ?? EMPTY_WEEKLY_ROWS; + const showDetails = detailKey !== null; + const groupLabel = (group: { values: string[] }) => dimensions.length + ? dimensions.map((dimension, index) => `${labels[dimension]}:${label(dimension, group.values[index])}`).join(' · ') + : '当前筛选结果'; + const parentTotals = useMemo(() => mergeActive ? dimensionParentTotals(groups, dimensions.length, group => group.values, group => group.rows.length) : [], [groups, dimensions.length, mergeActive]); + const childCounts = useMemo(() => mergeActive ? dimensionChildCounts(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]); + const groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails }); + const detailProgress = useProgressiveRows({ total: detailRows.length, rootRef: scrollRoot, resetKey: detailRows, enabled: showDetails }); + const shownGroups = useMemo(() => groups.slice(0, groupProgress.visibleCount), [groups, groupProgress.visibleCount]); + const completeSpans = useMemo(() => mergeActive ? dimensionRowSpans(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]); + const groupSpans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]); + const shownDetails = useMemo(() => detailRows.slice(0, detailProgress.visibleCount), [detailRows, detailProgress.visibleCount]); + const updateFilters = (next: Partial>) => { setFilters(next); setDetailKey(null); }; + const setFilter = (dimension: Dimension, selected: string[] | undefined) => { + const next = { ...filters }; + if (selected === undefined) delete next[dimension]; else next[dimension] = selected; + updateFilters(next); + }; + const updateDimensions = (next: Dimension[]) => { setDimensions(next); setDetailKey(null); }; + const activeFilters = dimensionsAvailable.filter(dimension => filters[dimension]?.length); + const filterOptions = useMemo(() => Object.fromEntries(dimensionsAvailable.map(dimension => { + const eligible = rows.filter(row => dimensionsAvailable.every(other => other === dimension || !filters[other]?.length || filters[other]!.includes(value(row, other)))); + const counts = new Map(); + for (const row of eligible) { const entry = value(row, dimension); counts.set(entry, (counts.get(entry) || 0) + 1); } + for (const entry of filters[dimension] || []) if (!counts.has(entry)) counts.set(entry, 0); + return [dimension, [...counts].map(([entry, count]) => ({ value: entry, label: label(dimension, entry), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))]; + })), [rows, filters]); + + return
+ {!showDetails ? <> +
+

分析维度

点击名称分组,点击箭头筛选
+
+ {dimensionsAvailable.map(dimension => { + const options = filterOptions[dimension]; + return updateDimensions(dimensions.includes(dimension) ? dimensions.filter(item => item !== dimension) : [...dimensions, dimension])} onApplyFilter={selected => setFilter(dimension, selected)} />; + })} + + {activeFilters.length > 0 && } +
+
+
当前结果 {number(filtered.length)} 车次{groups.length} 个分组{dimensions.length ? dimensions.map(dimension => labels[dimension]).join(' × ') : '仅汇总,不分组'}
+

筛选分组可继续分析,查看明细可核对业务记录。

{dimensions.length > 1 && }
+ {groups.length ? <> +
{dimensions.length ? dimensions.map(dimension => ) : } + {shownGroups.map((group, rowIndex) => 0 ? 'analysis-group-start' : undefined}> + {dimensions.length ? dimensions.map((dimension, index) => { + const span = mergeActive ? groupSpans[rowIndex][index] : 1; + return span === 0 ? null : ; + }) : } + + + )} +
{labels[dimension]}统计范围数量(车次)占比分析操作
1 ? span : undefined} className={span > 1 ? 'analysis-parent-cell' : undefined}>{label(dimension, group.values[index])}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <>合计 {number(parentTotals[rowIndex][index])} 车次{labels[dimensions[index + 1]]}:{number(childCounts[rowIndex][index])} {dimensionCountUnit(dimensions[index + 1])}}当前筛选结果{number(group.rows.length)}{(group.rows.length / filtered.length * 100).toFixed(1)}%{dimensions.length > 0 && }
+
合计 {number(filtered.length)} 车次 · 占比基于当前筛选结果100%
+ + :

没有符合条件的数据,请调整筛选。

{activeFilters.length > 0 && }
} + : <> +
{detailGroup ? groupLabel(detailGroup) : '当前分组'}
+

交还车业务明细 {number(detailRows.length)} 车次

+ {detailRows.length ? <>
{shownDetails.map((row, index) => )}
车牌日期合同类型客户
{row.plate_number || '—'}{row.handover_date || '—'}{row.contract_type || '—'}{row.customer_name || '—'}
+ + :

当前分组暂无数据,请返回统计分析。

} + } +
; +} diff --git a/src/modules/assets/components/analysis/DimensionPicker.tsx b/src/modules/assets/components/analysis/DimensionPicker.tsx new file mode 100644 index 0000000..ff642ba --- /dev/null +++ b/src/modules/assets/components/analysis/DimensionPicker.tsx @@ -0,0 +1,77 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { ChevronDown, Filter, Search, X } from 'lucide-react'; +import './dimension-picker.css'; + +export interface DimensionPickerOption { value: string; label: string; count?: number } +export interface DimensionPickerProps { + label: string; + grouped: boolean; + order?: number; + options: DimensionPickerOption[]; + selectedValues?: string[]; + onToggleGroup: () => void; + onApplyFilter: (values: string[] | undefined) => void; +} + +/** A grouping switch and an independently applied, searchable multi-value filter. */ +export function DimensionPicker({ label, grouped, order, options, selectedValues, onToggleGroup, onApplyFilter }: DimensionPickerProps) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState([]); + const [query, setQuery] = useState(''); + const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 420 }); + const root = useRef(null); + const trigger = useRef(null); + const search = useRef(null); + const menuId = useId(); + const selected = selectedValues ?? []; + const labels = new Map(options.map(option => [option.value, option.label])); + const selectedLabel = selected.map(value => labels.get(value) || value || '未标注').join('、'); + const visible = options.filter(option => option.label.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())); + const close = (restoreFocus = true) => { setOpen(false); if (restoreFocus) trigger.current?.focus(); }; + const show = () => { + if (open) { close(); return; } + setDraft([...selected]); setQuery(''); setOpen(true); + }; + + useEffect(() => { + if (!open) return; + const place = () => { + const rect = trigger.current?.getBoundingClientRect(); + if (!rect) return; + const width = Math.min(320, window.innerWidth - 16); + const roomBelow = window.innerHeight - rect.bottom - 12; + const useAbove = roomBelow < 220 && rect.top > roomBelow; + const height = Math.min(420, Math.max(160, useAbove ? rect.top - 12 : roomBelow)); + setPosition({ left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)), top: useAbove ? Math.max(8, rect.top - height - 5) : rect.bottom + 5, maxHeight: height }); + }; + place(); + const frame = requestAnimationFrame(() => search.current?.focus()); + const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false); }; + document.addEventListener('pointerdown', outside); + window.addEventListener('resize', place); + window.addEventListener('scroll', place, true); + return () => { cancelAnimationFrame(frame); document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true); }; + }, [open]); + + return
+
+ + +
+ {selected.length ? : null} + {open ? : null} +
; +} diff --git a/src/modules/assets/components/analysis/ProgressiveRowsStatus.tsx b/src/modules/assets/components/analysis/ProgressiveRowsStatus.tsx new file mode 100644 index 0000000..4cfa77a --- /dev/null +++ b/src/modules/assets/components/analysis/ProgressiveRowsStatus.tsx @@ -0,0 +1,18 @@ +import type { RefCallback } from 'react'; +import './progressive-rows.css'; + +interface Props { + visibleCount: number; + total: number; + hasMore: boolean; + loadMore: () => void; + sentinelRef: RefCallback; + unit?: string; +} + +export function ProgressiveRowsStatus({ visibleCount, total, hasMore, loadMore, sentinelRef, unit = '条' }: Props) { + return
+ 已加载 {visibleCount.toLocaleString('zh-CN')} / {total.toLocaleString('zh-CN')} {unit} + {hasMore ? <>下滚继续加载 : 已显示全部} +
; +} diff --git a/src/modules/assets/components/analysis/asset-analysis.css b/src/modules/assets/components/analysis/asset-analysis.css new file mode 100644 index 0000000..41ce7e2 --- /dev/null +++ b/src/modules/assets/components/analysis/asset-analysis.css @@ -0,0 +1,172 @@ +.asset-analysis-overlay { position:fixed; inset:0; z-index:70; background:rgb(0 0 0 / .4); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; padding:24px; } +.asset-analysis-dialog { --analysis-line:#d5deea; --analysis-boundary:#b8c6d8; --analysis-accent:#2563eb; --analysis-tint:#eff6ff; display:flex; flex-direction:column; width:min(1040px,100%); max-height:90dvh; min-height:430px; background:white; border-radius:12px; box-shadow:0 24px 60px rgb(15 23 42 / .2); overflow:hidden; color:#334155; font-size:12px; outline:none; } +.asset-analysis-dialog.analysis-amber { --analysis-accent:#b45309; --analysis-tint:#fffbeb; } +.asset-analysis-dialog button { cursor:pointer; font:inherit; transition:background-color .15s,color .15s; } +.asset-analysis-dialog button:disabled { cursor:default; opacity:.45; } +.asset-analysis-dialog button:focus-visible,.asset-analysis-dialog input:focus-visible,.asset-analysis-dialog select:focus-visible { outline:2px solid #2563eb; outline-offset:2px; } +.analysis-header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:14px 18px; background:#1e293b; color:white; flex-shrink:0; } +.analysis-header h2 { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:650; } +.analysis-header h2 svg { color:#60a5fa; } +.analysis-header p { font-size:11px; color:#cbd5e1; margin-top:4px; line-height:1.5; overflow-wrap:anywhere; } +.analysis-header button { padding:9px; border-radius:6px; flex-shrink:0; } +.analysis-header button:hover { background:#334155; } +.analysis-scroll { overflow-y:auto; overscroll-behavior:contain; min-height:0; padding:16px 18px; flex:1; } +.analysis-builder { border:1px solid #e2e8f0; border-radius:8px; padding:12px; background:#fff; } +.analysis-builder-heading { display:flex; align-items:center; flex-wrap:wrap; gap:8px; margin-bottom:10px; } +.analysis-builder-heading h3 { font-size:12px; font-weight:650; color:#1e293b; } +.analysis-builder-heading>span { color:#64748b; font-size:11px; } +.analysis-reset { display:inline-flex; align-items:center; gap:4px; color:#64748b; margin-left:auto; padding:3px 0; font-size:11px!important; } +.analysis-reset:hover { color:#2563eb; } +.analysis-dimension-options { display:flex; flex-wrap:wrap; gap:6px; } +.analysis-dimension-options button { display:inline-flex; align-items:center; gap:5px; min-height:30px; border:1px solid #e2e8f0; background:#f8fafc; color:#475569; padding:5px 9px; border-radius:5px; font-size:12px; } +.analysis-dimension-options button:hover { border-color:#93c5fd; } +.analysis-dimension-options button[aria-pressed=true] { background:#eff6ff; color:#1d4ed8; border-color:#93c5fd; } +.analysis-dimension-order { display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; font-size:10px; border-radius:3px; background:#2563eb; color:white; } +.analysis-filter-row { display:flex; align-items:center; flex-wrap:wrap; gap:6px; border-top:1px solid #edf2f7; margin-top:12px; padding-top:10px; } +.analysis-filter-label { display:inline-flex; align-items:center; gap:4px; color:#475569; font-weight:600; margin-right:3px; } +.analysis-filter-chip { display:inline-flex; align-items:center; max-width:100%; background:#f1f5f9; border:1px solid #e2e8f0; border-radius:5px; overflow:hidden; } +.analysis-filter-chip button { padding:5px 7px; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.analysis-filter-chip button:hover { background:#e2e8f0; } +.analysis-filter-chip button:last-child { flex-shrink:0; padding:6px; } +.analysis-add-filter { display:inline-flex; align-items:center; gap:3px; border:1px dashed #94a3b8; border-radius:5px; padding:5px 7px; color:#475569; background:white; } +.analysis-add-filter:hover { color:#2563eb; border-color:#2563eb; } +.analysis-filter-empty { color:#64748b; font-size:11px; } +.analysis-text-button { color:#2563eb; padding:5px; } +.analysis-filter-editor { margin-top:12px; border:1px solid #bfdbfe; border-radius:7px; box-shadow:0 4px 16px rgb(15 23 42 / .05); background:#fff; } +.analysis-filter-editor-top { display:flex; align-items:center; gap:12px; padding:10px 12px; border-bottom:1px solid #e2e8f0; } +.analysis-filter-editor-top>label:first-child { display:flex; align-items:center; gap:6px; white-space:nowrap; } +.analysis-filter-editor select { padding:5px 8px; border:1px solid #cbd5e1; background:white; border-radius:5px; } +.analysis-filter-search { display:flex; align-items:center; gap:6px; min-width:0; margin-left:auto; border:1px solid #e2e8f0; padding:6px 8px; border-radius:5px; color:#64748b; } +.analysis-filter-search input { min-width:0; width:130px; outline:none; color:#334155; } +.analysis-icon-button { padding:5px; } +.analysis-filter-options { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:3px 12px; padding:8px 12px; max-height:190px; overflow:auto; } +.analysis-filter-options label { display:flex; align-items:center; gap:7px; min-width:0; padding:7px 4px; cursor:pointer; border-radius:4px; } +.analysis-filter-options label:hover { background:#f8fafc; } +.analysis-filter-options input { accent-color:#2563eb; flex-shrink:0; } +.analysis-filter-options label span { overflow-wrap:anywhere; } +.analysis-filter-options small { margin-left:auto; color:#64748b; font-size:11px; white-space:nowrap; } +.analysis-filter-options>p { grid-column:1/-1; padding:12px 0; color:#64748b; } +.analysis-filter-editor-bottom { display:flex; align-items:center; flex-wrap:wrap; gap:8px; border-top:1px solid #e2e8f0; padding:8px 12px; } +.analysis-filter-editor-bottom>span { color:#64748b; font-size:11px; margin-right:auto; } +.analysis-primary,.analysis-button { display:inline-flex; align-items:center; justify-content:center; gap:5px; min-height:30px; padding:6px 10px; font-size:12px; border-radius:5px; background:#2563eb; color:#fff; } +.analysis-primary:hover { background:#1d4ed8; } +.analysis-button { background:#f1f5f9; color:#475569; } +.analysis-result-bar { display:flex; align-items:center; gap:18px; flex-wrap:wrap; padding:13px 0 8px; color:#64748b; } +.analysis-result-bar strong { font-size:22px; font-weight:650; color:var(--analysis-accent); font-variant-numeric:tabular-nums; margin:0 5px; } +.analysis-result-dimensions { margin-left:auto; font-size:11px; max-width:65%; line-height:1.7; } +.analysis-inventory-subsets { display:flex; align-items:center; flex-wrap:wrap; gap:6px 16px; margin:2px 0 12px; font-size:12px; } +.analysis-inventory-subsets strong { font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; } +.analysis-inventory-subsets small { font-size:11px; color:#64748b; } +.analysis-pending-count { color:#2563eb; font-variant-numeric:tabular-nums; } +.analysis-abnormal-count { color:#b45309; font-variant-numeric:tabular-nums; } +.analysis-zero-count { color:#94a3b8; font-variant-numeric:tabular-nums; } +.analysis-definition { font-size:11px; line-height:1.7; padding:8px 10px; background:#fffbeb; color:#92400e; border-radius:5px; margin:8px 0; } +.analysis-table-tools { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; margin:10px 0; } +.analysis-table-tools h3 { font-size:13px; font-weight:600; color:#1e293b; } +.analysis-table-tools h3 small { font-size:11px; color:#64748b; font-weight:400; margin-left:6px; } +.analysis-table-tools>p { font-size:11px; color:#64748b; } +.analysis-table-tools label { display:flex; align-items:center; gap:6px; font-size:11px; color:#64748b; } +.analysis-table-tools select { border:1px solid #e2e8f0; border-radius:5px; padding:5px 7px; background:white; color:#475569; font-size:11px; } +.analysis-group-table { overflow:auto; border:1px solid var(--analysis-boundary); border-radius:6px 6px 0 0; } +.analysis-group-table table { border-collapse:collapse; width:100%; font-size:12px; } +.analysis-group-table th { padding:10px 12px; color:#64748b; font-weight:500; text-align:left; background:#f8fafc; white-space:nowrap; } +.analysis-group-table td { padding:10px 12px; border-top:1px solid var(--analysis-line); min-width:84px; max-width:230px; overflow-wrap:anywhere; } +.analysis-group-table tbody tr:hover { background:#f8fafc; } +.analysis-group-table .analysis-numeric { text-align:right; white-space:nowrap; } +.analysis-group-table .analysis-numeric strong { font-size:14px; font-weight:650; color:var(--analysis-accent); font-variant-numeric:tabular-nums; } +.analysis-share-cell { min-width:90px!important; text-align:right; font-variant-numeric:tabular-nums; color:#64748b; } +.analysis-bar-track { display:block; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden; margin-top:5px; } +.analysis-bar-track>span { display:block; height:100%; background:var(--analysis-accent); border-radius:2px; } +.analysis-group-actions { white-space:nowrap; min-width:170px!important; } +.analysis-group-actions button { display:inline-flex; align-items:center; gap:4px; padding:5px 6px; color:#2563eb; font-size:11px; border-radius:4px; } +.analysis-group-actions button:hover { background:#eff6ff; } +.analysis-group-actions button+button { margin-left:5px; } +.analysis-table-total { display:flex; justify-content:space-between; gap:12px; padding:10px 12px; color:#64748b; font-size:11px; background:#f8fafc; border:1px solid var(--analysis-boundary); border-top:0; border-radius:0 0 6px 6px; } +.analysis-navigation { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 18px; border-bottom:1px solid #e2e8f0; flex-shrink:0; } +.analysis-navigation nav { display:flex; flex-wrap:wrap; gap:6px; } +.analysis-back { display:flex; gap:5px; align-items:center; font-size:12px; color:#2563eb; white-space:nowrap; padding:5px 0; } +.analysis-detail-scope { color:#64748b; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.analysis-search { border:1px solid #e2e8f0; padding:6px 8px; border-radius:5px; } +.analysis-search input { width:220px; color:#334155; outline:none; min-width:0; font-size:12px; } +.analysis-detail-table { overflow-x:auto; } +.analysis-detail-table table { width:100%; min-width:600px; border-collapse:collapse; font-size:12px; } +.analysis-detail-table th { font-weight:600; text-align:left; background:#f8fafc; padding:10px; color:#64748b; } +.analysis-detail-table td { padding:10px; border-bottom:1px solid #e2e8f0; } +.analysis-detail-table td small { display:block; font-size:11px; color:#64748b; margin-top:3px; } +.analysis-details .text-base { font-size:13px; }.analysis-details .text-sm { font-size:12px; } +.analysis-message { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:40px 16px; text-align:center; color:#64748b; flex:1; font-size:12px; } +.analysis-message h3 { font-size:13px; font-weight:600; color:#334155; } +.analysis-footer { display:flex; justify-content:space-between; gap:8px; flex-wrap:wrap; font-size:10px; color:#64748b; padding:9px 18px; background:#f8fafc; border-top:1px solid #e2e8f0; flex-shrink:0; } +/* Legacy weekly entries share the compact analysis vocabulary. */ +.analysis-summary { display:flex; align-items:center; justify-content:space-between; gap:15px; background:var(--analysis-tint); padding:12px; border-radius:6px; margin:10px 0; } +.analysis-summary strong { display:block; font-size:22px; color:var(--analysis-accent); }.analysis-summary small { font-size:11px; margin-left:4px; } +.analysis-row { display:grid; grid-template-columns:1fr 1fr auto 16px; gap:12px; width:100%; text-align:left; align-items:center; padding:12px; border-bottom:1px solid #e2e8f0; } +@media(max-width:640px) { + .asset-analysis-overlay { padding:8px; }.asset-analysis-dialog { max-height:94dvh; min-height:58dvh; border-radius:10px; } + .analysis-header { padding:12px; }.analysis-scroll { padding:12px; }.analysis-builder { padding:10px; } + .analysis-builder-heading>span { font-size:10px; }.analysis-dimension-options { gap:5px; }.analysis-dimension-options button { min-height:34px; padding:6px 8px; } + .analysis-filter-row { gap:6px; }.analysis-filter-empty { width:100%; margin-top:1px; font-size:10px; } + .analysis-filter-editor-top { flex-wrap:wrap; gap:8px; padding:8px; }.analysis-filter-search { order:3; width:100%; }.analysis-filter-search input { width:100%; }.analysis-filter-editor-top .analysis-icon-button { margin-left:auto; } + .analysis-filter-options { grid-template-columns:repeat(2,minmax(0,1fr)); gap:2px 8px; padding:8px; }.analysis-filter-options label { padding:9px 2px; }.analysis-filter-options small { font-size:10px; } + .analysis-filter-editor-bottom { gap:6px; padding:8px; }.analysis-filter-editor-bottom>span { width:100%; }.analysis-filter-editor-bottom .analysis-text-button { margin-right:auto; } + .analysis-result-bar { gap:10px; }.analysis-result-dimensions { max-width:100%; width:100%; margin:0; font-size:10px; } + .analysis-table-tools>p { font-size:10px; }.analysis-definition { font-size:10px; } + .analysis-group-table { border:0; overflow:visible; }.analysis-group-table table,.analysis-group-table tbody { display:block; }.analysis-group-table thead { display:none; } + .analysis-group-table tr { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); border:1px solid #e2e8f0; border-radius:6px; margin-bottom:8px; padding:7px; gap:4px 8px; } + .analysis-group-table td { display:flex; flex-direction:column; gap:3px; min-width:0!important; max-width:none; padding:4px; border:0; font-size:12px; } + .analysis-group-table td[data-label]::before { content:attr(data-label); font-size:10px; color:#64748b; } + .analysis-group-table .analysis-numeric,.analysis-group-table .analysis-share-cell { text-align:left; }.analysis-group-table .analysis-share-cell .analysis-bar-track { max-width:100px; } + .analysis-group-table .analysis-group-actions { grid-column:1/-1; display:flex; flex-direction:row; justify-content:flex-end; border-top:1px solid #edf2f7; padding-top:5px; } + .analysis-group-actions button { min-height:32px; }.analysis-table-total { border-radius:6px; border-top:1px solid #e2e8f0; font-size:10px; } + .analysis-navigation { padding:8px 12px; }.analysis-search { width:100%; }.analysis-search input { width:100%; }.analysis-footer { padding:8px 12px; font-size:10px; } +} +@media(prefers-reduced-motion:reduce) { .asset-analysis-dialog * { transition:none!important; animation:none!important; } } +/* Dimension labels and their Excel-style filter dropdowns form one control. */ +.analysis-builder { position:relative; } +.analysis-dimension-column { display:inline-flex; min-width:0; max-width:100%; } +.analysis-dimension-options .analysis-dimension-name { border-radius:5px 0 0 5px; white-space:nowrap; } +.analysis-dimension-options .analysis-dimension-dropdown { border-radius:0 5px 5px 0; border-left:0; padding:5px 7px; min-width:28px; justify-content:center; } +.analysis-dimension-dropdown svg { flex-shrink:0; } +.analysis-inline-filter { max-width:105px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px; } +.analysis-dimension-column.has-filter .analysis-dimension-dropdown { background:#eff6ff; color:#2563eb; border-color:#93c5fd; } +.analysis-active-note { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:4px; margin-top:9px; font-size:10px; color:#64748b; } +.analysis-active-note .analysis-text-button { font-size:10px; } +.analysis-column-popover { position:absolute; z-index:5; width:330px; max-width:calc(100% - 16px); margin-top:0; box-shadow:0 8px 28px rgb(15 23 42 / .18); } +.analysis-column-popover .analysis-filter-editor-top { gap:7px; padding:9px 10px; } +.analysis-column-popover .analysis-filter-editor-top strong { font-size:12px; } +.analysis-column-popover .analysis-filter-editor-top>span { font-size:10px; color:#64748b; } +.analysis-column-popover .analysis-icon-button { margin-left:auto; } +.analysis-column-popover .analysis-filter-search { margin:8px 10px 0; width:auto; } +.analysis-column-popover .analysis-filter-search input { width:100%; font-size:12px; } +.analysis-filter-selection { display:flex; align-items:center; gap:12px; padding:8px 12px 2px; font-size:11px; } +.analysis-filter-selection button { color:#2563eb; padding:3px 0; } +.analysis-filter-selection>span { margin-left:auto; color:#64748b; } +.analysis-column-popover .analysis-filter-options { grid-template-columns:1fr; max-height:180px; padding:4px 10px 8px; } +.analysis-column-popover .analysis-filter-options label { padding:7px 3px; } +.analysis-column-popover .analysis-filter-editor-bottom { gap:6px; padding:8px 10px; } +.analysis-column-popover .analysis-filter-editor-bottom>span { width:auto; margin-right:auto; font-size:10px; } +@media(max-width:640px) { .analysis-column-popover .analysis-filter-options { max-height:160px; }.analysis-inline-filter { max-width:72px; } } +.analysis-table-options { display:flex; flex-wrap:wrap; align-items:center; gap:14px; } +.analysis-parent-total { display:block; margin-top:7px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; } +.analysis-parent-total strong { color:var(--analysis-accent); font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; } +.analysis-parent-children { display:block; margin-top:3px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; } +.analysis-parent-children strong { font-weight:600; color:#475569; font-variant-numeric:tabular-nums; } +.analysis-merge-toggle { cursor:pointer; white-space:nowrap; } +.analysis-merge-toggle input { accent-color:#2563eb; width:13px; height:13px; } +.analysis-group-table.is-merged td { vertical-align:top; } +.analysis-group-table.is-merged td[rowspan]:not([rowspan="1"]) { background:#f8fafc; border-right:1px solid var(--analysis-boundary); border-bottom:1px solid var(--analysis-boundary); font-weight:500; } +@media(max-width:640px) { + .analysis-group-table.is-merged { overflow:auto; border:1px solid var(--analysis-boundary); border-radius:6px; } + .analysis-group-table.is-merged table { display:table; min-width:560px; } + .analysis-group-table.is-merged thead { display:table-header-group; } + .analysis-group-table.is-merged tbody { display:table-row-group; } + .analysis-group-table.is-merged tr { display:table-row; padding:0; border:0; } + .analysis-group-table.is-merged td { display:table-cell; padding:10px 12px; border-top:1px solid var(--analysis-line); min-width:80px!important; } + .analysis-group-table.is-merged td[data-label]::before { display:none; } + .analysis-group-table.is-merged .analysis-numeric,.analysis-group-table.is-merged .analysis-share-cell { text-align:right; } + .analysis-group-table.is-merged .analysis-group-actions { display:table-cell; min-width:170px!important; } +} + +/* Keep a one-pixel grid; darker boundaries separate parent groups. */ +.analysis-group-table tbody tr:first-child > td, +.analysis-group-table.is-merged .analysis-group-start > td { border-top-color:var(--analysis-boundary); } diff --git a/src/modules/assets/components/analysis/dimension-picker.css b/src/modules/assets/components/analysis/dimension-picker.css new file mode 100644 index 0000000..9561424 --- /dev/null +++ b/src/modules/assets/components/analysis/dimension-picker.css @@ -0,0 +1,34 @@ +.adp-row { display:flex; flex-wrap:wrap; align-items:flex-start; gap:6px; } +.analysis-dimension-picker { display:inline-flex; flex-direction:column; align-items:stretch; min-width:0; max-width:190px; vertical-align:top; font-size:12px; } +.analysis-dimension-picker .adp-split { display:flex; min-height:30px; border:1px solid #e2e8f0; border-radius:5px; background:#f8fafc; overflow:hidden; } +.analysis-dimension-picker .adp-split button { display:inline-flex; align-items:center; justify-content:center; gap:4px; min-height:28px; padding:5px 8px; border:0; border-radius:0; background:transparent; color:#475569; font-size:12px; line-height:1.3; } +.analysis-dimension-picker .adp-split .adp-arrow { border-left:1px solid #e2e8f0; padding:5px 6px; font-size:10px; } +.analysis-dimension-picker.adp-grouped .adp-split { background:#eff6ff; border-color:#93c5fd; } +.analysis-dimension-picker.adp-grouped .adp-name { color:#1d4ed8; } +.analysis-dimension-picker.adp-filtered .adp-arrow { color:#1d4ed8; background:#dbeafe; } +.analysis-dimension-picker .adp-split button:hover { background:#e2e8f0; } +.analysis-dimension-picker .adp-order { display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; background:#2563eb; border-radius:3px; font-size:10px; color:white; } +.analysis-dimension-picker .adp-selected { text-align:left; padding:3px 2px 0; border:0; background:none; color:#2563eb; font-size:10px; max-width:190px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.analysis-dimension-picker .adp-menu { position:fixed; z-index:1100; display:flex; flex-direction:column; width:min(320px,calc(100vw - 16px)); border:1px solid #cbd5e1; border-radius:7px; background:#fff; color:#334155; box-shadow:0 8px 30px rgb(15 23 42 / .17); overflow:hidden; font-size:12px; } +.analysis-dimension-picker .adp-menu button { padding:5px 8px; border:0; border-radius:4px; background:transparent; color:#475569; font-size:11px; min-height:26px; line-height:1.4; } +.analysis-dimension-picker .adp-menu button:hover { background:#f1f5f9; } +.analysis-dimension-picker .adp-menu-heading { display:flex; align-items:center; justify-content:space-between; padding:7px 10px; border-bottom:1px solid #e2e8f0; flex-shrink:0; } +.analysis-dimension-picker .adp-menu-heading strong { font-size:12px; font-weight:600; } +.analysis-dimension-picker .adp-menu-heading button { padding:4px; min-height:22px; } +.analysis-dimension-picker .adp-search { display:flex; align-items:center; gap:6px; margin:9px 10px 5px; padding:6px 8px; border:1px solid #e2e8f0; border-radius:5px; flex-shrink:0; color:#64748b; } +.analysis-dimension-picker .adp-search input { width:100%; min-width:0; border:0; outline:none; background:white; color:#334155; font-size:12px; } +.analysis-dimension-picker .adp-select-tools { display:flex; align-items:center; gap:2px; padding:0 6px 4px; flex-shrink:0; } +.analysis-dimension-picker .adp-select-tools button { color:#2563eb; font-size:10px; padding:4px; } +.analysis-dimension-picker .adp-select-tools>span { margin-left:auto; padding-right:4px; font-size:10px; color:#64748b; } +.analysis-dimension-picker .adp-options { min-height:0; flex:1; overflow-y:auto; overscroll-behavior:contain; padding:0 8px 6px; } +.analysis-dimension-picker .adp-options label { display:flex; align-items:center; gap:7px; min-height:30px; padding:6px 4px; border-radius:4px; cursor:pointer; font-size:12px; } +.analysis-dimension-picker .adp-options label:hover { background:#f8fafc; } +.analysis-dimension-picker .adp-options input { accent-color:#2563eb; flex-shrink:0; width:13px; height:13px; } +.analysis-dimension-picker .adp-options label>span { overflow-wrap:anywhere; } +.analysis-dimension-picker .adp-options small { margin-left:auto; padding-left:5px; color:#64748b; font-size:10px; font-variant-numeric:tabular-nums; } +.analysis-dimension-picker .adp-options p { padding:16px; text-align:center; color:#64748b; font-size:11px; } +.analysis-dimension-picker .adp-menu-footer { border-top:1px solid #e2e8f0; padding:8px 10px; flex-shrink:0; } +.analysis-dimension-picker .adp-menu-footer>span { display:block; font-size:10px; color:#64748b; margin-bottom:7px; } +.analysis-dimension-picker .adp-menu-footer>div { display:flex; justify-content:flex-end; gap:6px; } +.analysis-dimension-picker .adp-menu-footer .adp-apply { background:#2563eb; color:white; } +.analysis-dimension-picker .adp-menu-footer .adp-apply:hover { background:#1d4ed8; } diff --git a/src/modules/assets/components/analysis/progressive-rows.css b/src/modules/assets/components/analysis/progressive-rows.css new file mode 100644 index 0000000..86964e0 --- /dev/null +++ b/src/modules/assets/components/analysis/progressive-rows.css @@ -0,0 +1,5 @@ +.analysis-progressive-status { display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:8px 14px; min-height:44px; padding:10px 12px; color:#64748b; font-size:11px; font-variant-numeric:tabular-nums; } +.analysis-progressive-hint { color:#94a3b8; } +.analysis-progressive-status button { padding:5px 10px; border:1px solid #e2e8f0; border-radius:5px; background:#fff; color:#2563eb; cursor:pointer; font:inherit; } +.analysis-progressive-status button:hover { background:#eff6ff; } +.analysis-progressive-status button:focus-visible { outline:2px solid #2563eb; outline-offset:2px; } diff --git a/src/modules/assets/components/analysis/useProgressiveRows.ts b/src/modules/assets/components/analysis/useProgressiveRows.ts new file mode 100644 index 0000000..6d88533 --- /dev/null +++ b/src/modules/assets/components/analysis/useProgressiveRows.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useMemo, useState, type RefObject } from 'react'; + +interface ProgressiveRowsOptions { + total: number; + rootRef: RefObject; + resetKey: unknown; + enabled?: boolean; + batchSize?: number; +} + +/** Render a cumulative prefix, loading another batch near the actual scroll body's bottom. */ +export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, batchSize = 40 }: ProgressiveRowsOptions) { + const context = useMemo(() => ({ resetKey, enabled, batchSize }), [resetKey, enabled, batchSize]); + const [progress, setProgress] = useState(() => ({ context, limit: batchSize })); + const [sentinel, setSentinel] = useState(null); + const visibleCount = Math.min(total, progress.context === context ? progress.limit : batchSize); + const hasMore = visibleCount < total; + const loadMore = useCallback(() => { + if (!enabled) return; + setProgress(previous => ({ context, limit: Math.min(total, (previous.context === context ? previous.limit : batchSize) + batchSize) })); + }, [enabled, context, total, batchSize]); + + useEffect(() => { + if (enabled && rootRef.current) rootRef.current.scrollTop = 0; + }, [context, enabled, rootRef]); + + useEffect(() => { + const root = rootRef.current; + if (!enabled || !hasMore || !root || !sentinel || typeof IntersectionObserver === 'undefined') return; + let active = true; + let queued = false; + let frame: number | undefined; + const observer = new IntersectionObserver(entries => { + if (!active || queued || !entries.some(entry => entry.isIntersecting)) return; + queued = true; + // One append per observer: let layout settle before deciding whether more rows fit. + frame = requestAnimationFrame(loadMore); + }, { root, rootMargin: '0px 0px 240px 0px', threshold: 0 }); + observer.observe(sentinel); + return () => { + active = false; + observer.disconnect(); + if (frame !== undefined) cancelAnimationFrame(frame); + }; + }, [enabled, hasMore, rootRef, sentinel, visibleCount, loadMore]); + + return { visibleCount, total, hasMore, loadMore, sentinelRef: setSentinel }; +} diff --git a/src/modules/assets/components/flow-analysis-modal.css b/src/modules/assets/components/flow-analysis-modal.css new file mode 100644 index 0000000..e548e48 --- /dev/null +++ b/src/modules/assets/components/flow-analysis-modal.css @@ -0,0 +1,86 @@ +.bi-flow-modal-backdrop { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgb(2 6 23 / 45%); backdrop-filter: blur(4px); } +.bi-flow-modal-parent-total { display:block; margin-top:7px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; } +.bi-flow-modal-parent-total strong { color:#2563eb; font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; } +.bi-flow-modal-parent-children { display:block; margin-top:3px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; } +.bi-flow-modal-parent-children strong { font-weight:600; color:#475569; font-variant-numeric:tabular-nums; } +.bi-flow-modal { --flow-line:#d5deea; --flow-boundary:#b8c6d8; display: flex; flex-direction: column; width: 100%; max-width: 960px; max-height: 88dvh; min-width: 0; overflow: hidden; background: white; border-radius: 12px; color: #334155; font-size: 12px; box-shadow: 0 24px 64px rgb(15 23 42 / 20%); } +.bi-flow-modal button, .bi-flow-modal select { font: inherit; cursor: pointer; } +.bi-flow-modal button:disabled { cursor: not-allowed; opacity: .4; } +.bi-flow-modal button:focus-visible, .bi-flow-modal select:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; } +.bi-flow-modal-header { flex-shrink: 0; padding: 14px 18px; background: #1e293b; color: white; } +.bi-flow-modal-header-row { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; } +.bi-flow-modal-header h3 { margin: 0 0 5px; font-size: 14px; line-height: 1.5; font-weight: 700; } +.bi-flow-modal-header p { margin: 0; font-size: 11px; color: #cbd5e1; } +.bi-flow-modal-close { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 0; border-radius: 50%; background: rgb(255 255 255 / 10%); color: white; } +.bi-flow-modal-close:hover { background: rgb(255 255 255 / 20%); } +.bi-flow-modal-header .bi-flow-modal-definition { margin-top: 8px; color: #94a3b8; font-size: 10px; } +.bi-flow-modal-body { overflow-y: auto; overscroll-behavior: contain; min-height:0; flex:1; padding: 16px 18px; } +.bi-flow-modal-controls { padding-bottom: 14px; border-bottom: 1px solid #e2e8f0; } +.bi-flow-modal-control-row { display: flex; align-items: center; gap: 12px; } +.bi-flow-modal-control-label { flex: 0 0 50px; color: #64748b; font-size: 11px; } +.bi-flow-modal-chip { display: inline-flex; align-items: center; gap: 4px; min-height: 28px; padding: 4px 9px; border: 1px solid #e2e8f0; border-radius: 6px; background: white; color: #64748b; font-size: 12px; } +.bi-flow-modal-chip:hover { border-color: #93c5fd; } +.bi-flow-modal-chip-active { border-color: #bfdbfe; background: #eff6ff; color: #2563eb; } +.bi-flow-modal-control-hint { margin: 7px 0 12px 62px; font-size: 10px; color: #94a3b8; line-height: 1.6; } +.bi-flow-modal-clear { color: #64748b; background: none; border: 0; font-size: 10px !important; } +.bi-flow-modal-detail-nav { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; } +.bi-flow-modal-detail-nav > span { font-size: 11px; color: #64748b; overflow-wrap: anywhere; } +.bi-flow-modal-back { display: inline-flex; align-items: center; gap: 5px; border: 0; background: #f1f5f9; color: #475569; padding: 6px 8px; border-radius: 6px; font-size: 11px !important; } +.bi-flow-modal-back:hover { background: #e2e8f0; } +.bi-flow-modal-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; margin: 14px 0 16px; } +.bi-flow-modal-summary > div { padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.bi-flow-modal-summary > div + div { border-left: 1px solid #e2e8f0; } +.bi-flow-modal-summary span { font-size: 11px; color: #64748b; } +.bi-flow-modal-summary strong { color: #2563eb; font-size: 22px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; } +.bi-flow-modal strong small { margin-left: 4px; font-size: 10px; font-weight: 400; color: #64748b; } +.bi-flow-modal-purple { color: #7c3aed !important; } +.bi-flow-modal-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; } +.bi-flow-modal-section-heading h4 { margin: 0; font-size: 12px; font-weight: 600; color: #0f172a; } +.bi-flow-modal-section-heading p { margin: 4px 0 0; font-size: 10px; line-height: 1.6; color: #64748b; } +.bi-flow-modal-export { display: inline-flex; gap: 5px; align-items: center; background: #eff6ff; border: 1px solid #dbeafe; color: #2563eb; border-radius: 6px; padding: 6px 8px; font-size: 11px !important; } +.bi-flow-modal-export:hover { background: #dbeafe; } +.bi-flow-modal-table-wrap { max-width: 100%; overflow-x: auto; border: 1px solid var(--flow-boundary); border-radius: 8px; } +.bi-flow-modal-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 12px; } +.bi-flow-modal-table th { padding: 10px 12px; background: #f8fafc; color: #64748b; font-weight: 500; white-space: nowrap; } +.bi-flow-modal-table td { height: 40px; padding: 7px 12px; border-top: 1px solid var(--flow-line); line-height: 1.5; max-width: 200px; overflow-wrap: anywhere; } +.bi-flow-modal-table tr:hover td { background: #f8fafc; } +.bi-flow-modal-number { text-align: right; white-space: nowrap; width: 65px; } +.bi-flow-modal-number strong { font-size: 14px; font-weight: 600; color: #0f172a; font-variant-numeric: tabular-nums; } +.bi-flow-modal-share { width: 126px; white-space: nowrap; } +.bi-flow-modal-share > div { display: flex; align-items: center; justify-content: flex-end; gap: 7px; font-size: 11px; font-variant-numeric: tabular-nums; } +.bi-flow-modal-share > div > span:last-child { min-width: 42px; text-align: right; } +.bi-flow-modal-track { display: block; width: 48px; flex-shrink: 0; height: 5px; background: #f1f5f9; overflow: hidden; border-radius: 3px; } +.bi-flow-modal-fill { display: block; height: 100%; border-radius: 3px; } +.bi-flow-modal-fill-delivered { background: #2563eb; } +.bi-flow-modal-fill-returned { background: #ea580c; } +.bi-flow-modal-fill-replaced { background: #7c3aed; } +.bi-flow-modal-action { width: 78px; white-space: nowrap; text-align: right; } +.bi-flow-modal-action button { padding: 3px 0; color: #2563eb; font-size: 11px; background: none; border: 0; } +.bi-flow-modal-action button:hover { text-decoration: underline; } +.bi-flow-modal-detail-table { min-width: 800px; } +.bi-flow-modal-detail-table td { font-size: 12px; min-width: 80px; } +.bi-flow-modal-detail-table td:nth-child(3), .bi-flow-modal-detail-table td:nth-child(4) { min-width: 133px; font-size: 11px; } +.bi-flow-modal-empty { padding: 30px 12px; text-align: center; color: #94a3b8; font-size: 12px; background: #f8fafc; border-radius: 8px; } +@media (max-width: 640px) { + .bi-flow-modal-backdrop { padding: 0; align-items: flex-end; } + .bi-flow-modal { border-radius: 12px 12px 0 0; max-height: 90dvh; } + .bi-flow-modal-header, .bi-flow-modal-body { padding: 12px; } + .bi-flow-modal-control-row { align-items: flex-start; gap: 8px; } + .bi-flow-modal-control-label { flex-basis: 44px; padding-top: 6px; font-size: 10px; } + .bi-flow-modal-control-hint { margin-left: 52px; } + .bi-flow-modal-summary > div { padding: 10px 8px; } + .bi-flow-modal-summary span { font-size: 10px; } + .bi-flow-modal-summary strong { font-size: 22px; } + .bi-flow-modal-table th, .bi-flow-modal-table td { padding-left: 9px; padding-right: 9px; } + .bi-flow-modal-table th:not(.bi-flow-modal-number):not(.bi-flow-modal-share):not(.bi-flow-modal-action) { min-width: 72px; } + .bi-flow-modal-share { width: 65px; } + .bi-flow-modal-track { display: none; } +} + +.bi-flow-modal-merge-toggle { display:inline-flex; align-items:center; gap:6px; color:#64748b; font-size:11px; cursor:pointer; white-space:nowrap; } +.bi-flow-modal-merge-toggle input { width:13px; height:13px; accent-color:#2563eb; } +.bi-flow-modal-table td.bi-flow-modal-parent-cell { vertical-align:top; padding-top:11px; background:#f8fafc; border-right:1px solid var(--flow-boundary); border-bottom:1px solid var(--flow-boundary); font-weight:500; } + +/* Parent boundaries are stronger in color, never thicker. */ +.bi-flow-modal-table tbody tr:first-child > td, +.bi-flow-modal-table .bi-flow-modal-group-start > td { border-top-color:var(--flow-boundary); } diff --git a/src/modules/assets/components/overview/InventoryDesktopTable.tsx b/src/modules/assets/components/overview/InventoryDesktopTable.tsx index fe8c3e3..2019289 100644 --- a/src/modules/assets/components/overview/InventoryDesktopTable.tsx +++ b/src/modules/assets/components/overview/InventoryDesktopTable.tsx @@ -1,100 +1,176 @@ -import { Fragment, type Dispatch, type SetStateAction } from 'react'; -import { ArrowUpRight, ChevronRight } from 'lucide-react'; import { InventoryStatusCounts } from './InventoryStatusCounts'; -import { InventoryModelTags } from './InventoryModelTags'; -import { inventoryBrandGroups, inventoryRegionRows, inventorySelection, inventorySubtotalSelection, inventoryTotal, type InventoryDisplayGroup } from './inventory-presentation'; -import { inventoryTypeLabel, type InventoryFilters, type VehicleModalSelection } from '../../model'; +import { inventorySelection, type InventoryDisplayGroup } from './inventory-presentation'; +import type { InventoryFilters } from '../../model'; import type { RegionalInventoryStats } from '../../types'; +import { inventorySubtotalSelection } from './inventory-presentation'; +import React from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; +import type { InventoryStatisticsSectionProps } from './types'; -export interface InventoryListProps { - groups: InventoryDisplayGroup[]; - tab: 'region' | 'model'; - expanded: Set; - toggle: (name: string) => void; - total: number; - filters: InventoryFilters; - select: Dispatch>; +type InventoryDesktopTableProps = Pick< + InventoryStatisticsSectionProps, + | 'inventoryTab' + | 'inventoryByRegion' + | 'expandedInventoryRegions' + | 'toggleInventoryRegion' + | 'inventoryByModel' + | 'expandedInventoryTypes' + | 'toggleInventoryType' + | 'setVehicleSelection' +>; + +export function InventoryDesktopTable({ + inventoryTab, + inventoryByRegion, + expandedInventoryRegions, + toggleInventoryRegion, + inventoryByModel, + expandedInventoryTypes, + toggleInventoryType, + setVehicleSelection: setShowPlateNumbers, +}: InventoryDesktopTableProps) { + return ( +
+ + + + + + + + + + + {inventoryTab === 'region' ? ( + Object.entries(inventoryByRegion).map(([region, cities]) => ( + + toggleInventoryRegion(region)} + > + + + + {expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => ( + + {stats.map((stat, idx) => ( + + + + + + + ))} + + ))} + + + )) + ) : ( + Object.entries(inventoryByModel).map(([type, models]) => ( + + toggleInventoryType(type)} + > + + + + {expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => ( + + {stats.map((stat, idx) => ( + + + + + + + ))} + + ))} + + + )) + )} + +
{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}{inventoryTab === 'region' ? '品牌' : '品牌'}{inventoryTab === 'region' ? '车型' : '所在区域/城市'}库存数量
+
+ {expandedInventoryRegions.has(region) ? : } + {region} + { + e.stopPropagation(); + setShowPlateNumbers(inventorySubtotalSelection(Object.values(cities).flat(), `库存统计 - ${region}`)); + }}> + (共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台) + + +
+
+ {idx === 0 ? {city} : ''} + {stat.brand}{stat.model} + + {((stat.pending ?? 0) > 0 || (stat.abnormal ?? 0) > 0) && ( +
+ )} +
+
+ {expandedInventoryTypes.has(type) ? : } + {type} + { + e.stopPropagation(); + setShowPlateNumbers(inventorySubtotalSelection(Object.values(models).flat(), `库存统计 - ${type}`)); + }}> + (共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台) + + +
+
+ {idx === 0 ? {model} : ''} + {stat.brand}{stat.region} / {stat.city} + + {((stat.pending ?? 0) > 0 || (stat.abnormal ?? 0) > 0) && ( +
+ )} +
+
+ ); } -export function InventoryQuantity({ quantity, label, onClick }: { quantity: number; label: string; onClick: () => void }) { - return ; -} - -export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryListProps['select']) { +export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryStatisticsSectionProps['setVehicleSelection']) { select(inventorySelection(row, `库存统计 · ${row.city} · ${row.model}`, { vehicleType: row.type })); } -export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: Pick) { +export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: { tab: 'region' | 'model'; filters: InventoryFilters; select: InventoryStatisticsSectionProps['setVehicleSelection'] }) { select(inventorySelection(tab === 'model' ? { ...filters, type: group.name } : filters, `库存统计 · ${group.name}`, tab === 'region' ? { location: filters.city || group.name } : {})); } - -export function InventoryShare({ quantity, total }: { quantity: number; total: number }) { - const percent = total ? quantity / total * 100 : 0; - return
; -} - -function InventorySubtotal({ rows, label, select }: { rows: RegionalInventoryStats[]; label: string; select: InventoryListProps['select'] }) { - return
- - -
; -} - -function InventoryDetailRows({ rows, city, tab, total, select }: Pick & { rows: RegionalInventoryStats[]; city?: string }) { - const brands = inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model'); - return brands.map((brand, brandIndex) => brand.variants.map((variants, variantIndex) => - (tab === 'model' ? inventoryRegionRows(variants) : variants.map(row => ({ row, regionSpan: 0 }))).map(({ row, regionSpan }, index) => - - {tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && {city || '未分配'}} - {variantIndex === 0 && index === 0 && {brand.brand || '—'}} - {index === 0 && - {tab === 'region' ? {inventoryTypeLabel(row.type) || '未分类'} : } - - } - {tab === 'model' && regionSpan > 0 && {row.region || '未分配'} item.region === row.region)} label={row.region || '未分配'} />} - {tab === 'region' ? : row.city || '未分配'} - selectInventoryRow(row, select)} /> - - - ))); -} - -export function InventoryDesktopTable(props: InventoryListProps) { - const { groups, tab, expanded, toggle, total, select } = props; - return
- - - - {tab === 'region' && } - - {tab === 'region' && } - - {tab === 'model' && <>} - - - - {groups.map(group => - - - - - - - {expanded.has(group.name) && (tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) => - - )} - )} -
按{tab === 'region' ? '区域' : '车型'}分组的库存统计,占比基于当前筛选库存总数
区域 / 城市品牌车型类别区域城市库存数量库存占比
-
- - -
-
selectInventoryGroup(group, props)} />
-
; -} diff --git a/src/modules/assets/components/overview/InventoryMobileList.tsx b/src/modules/assets/components/overview/InventoryMobileList.tsx index cbcd14d..5fdfcee 100644 --- a/src/modules/assets/components/overview/InventoryMobileList.tsx +++ b/src/modules/assets/components/overview/InventoryMobileList.tsx @@ -1,77 +1,131 @@ -import { Fragment, useId, useRef, useState } from 'react'; -import { ChevronLeft, ChevronRight, MapPin, Truck } from 'lucide-react'; import { InventoryStatusCounts } from './InventoryStatusCounts'; -import { inventoryMobileDrilldown, inventoryMobileGroups, inventoryPage, inventorySubtotalSelection, inventoryTotal, type InventoryDisplayGroup } from './inventory-presentation'; -import { InventoryQuantity, InventoryShare, selectInventoryGroup, type InventoryListProps } from './InventoryDesktopTable'; +import { inventorySubtotalSelection } from './inventory-presentation'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import type { InventoryStatisticsSectionProps } from './types'; -function MobileGroupDetails({ group, tab, select }: Pick & { group: InventoryDisplayGroup }) { - const [path, setPath] = useState([]); - const [requestedPage, setPage] = useState(0); - const heading = useRef(null); - const view = inventoryMobileDrilldown(group.rows, tab, path); - const labels = { region: '区域', city: '城市', type: '车型', variant: '品牌 / 型号' }; - const { page, pageCount, start, rows: pageGroups } = inventoryPage(view.groups, requestedPage); - const activePath = view.parents.map(parent => parent.key); - const navigate = (nextPath: string[]) => { - setPath(nextPath); - setPage(0); - heading.current?.focus({ preventScroll: true }); - }; - const openVehicles = (rows: typeof group.rows, label: string) => select(inventorySubtotalSelection(rows, label)); +type InventoryMobileListProps = Pick< + InventoryStatisticsSectionProps, + | 'inventoryTab' + | 'inventoryByRegion' + | 'expandedInventoryRegions' + | 'toggleInventoryRegion' + | 'inventoryByModel' + | 'expandedInventoryTypes' + | 'toggleInventoryType' + | 'setVehicleSelection' +>; - return
- {view.parents.length > 0 && } -
-

{labels[view.dimension]}库存 · {view.groups.length} 项

- {view.parents.length > 0 && } +export function InventoryMobileList({ + inventoryTab, + inventoryByRegion, + expandedInventoryRegions, + toggleInventoryRegion, + inventoryByModel, + expandedInventoryTypes, + toggleInventoryType, + setVehicleSelection: setShowPlateNumbers, +}: InventoryMobileListProps) { + return ( +
+ {inventoryTab === 'region' ? ( + Object.entries(inventoryByRegion).map(([region, cities]) => ( +
+
toggleInventoryRegion(region)} + > +
+ {expandedInventoryRegions.has(region) ? : } + {region} +
+
+ + {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台 + + +
+
+ + {expandedInventoryRegions.has(region) && ( +
+ {Object.entries(cities).map(([city, stats]) => ( +
+
{city}
+
+ {stats.map((stat, idx) => ( +
+
+ {stat.brand} + {stat.model} +
+
+ + +
+
+ ))} +
+
+ ))} +
+ )} +
+ )) + ) : ( + Object.entries(inventoryByModel).map(([type, models]) => ( +
+
toggleInventoryType(type)} + > +
+ {expandedInventoryTypes.has(type) ? : } + {type} +
+
+ + {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台 + + +
+
+ + {expandedInventoryTypes.has(type) && ( +
+ {Object.entries(models).map(([model, stats]) => ( +
+
{model}
+
+ {stats.map((stat, idx) => ( +
+
+ {stat.brand} + {stat.region} / {stat.city} +
+
+ + +
+
+ ))} +
+
+ ))} +
+ )} +
+ )) + )}
-
{labels[view.dimension]}库存(台)
-
    - {pageGroups.map(item =>
  • - - openVehicles(item.rows, item.label)} /> - {item.rows.some(row => row.abnormal || row.pending) &&
    } -
  • )} -
-
当前范围合计{inventoryTotal(view.rows).toLocaleString()} 台
- {pageCount > 1 && } -
; -} - -export function InventoryMobileList(props: InventoryListProps) { - const { groups, tab, total } = props; - const [activeGroup, setActiveGroup] = useState(null); - const id = useId(); - return
-

点击名称逐层查看,点击数量查看车辆

- {groups.map((group, index) =>
-
- - selectInventoryGroup(group, props)} /> -
-
库存占比
- {(group.rows.some(row => row.abnormal || row.pending)) &&
} - -
)} -
; + ); } diff --git a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx index c899edf..fd669cc 100644 --- a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx +++ b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx @@ -1,121 +1,213 @@ -import { useId, useRef, useState } from 'react'; -import { ArrowUpRight, Boxes, ChevronDown, ChevronsDownUp, ChevronsUpDown, Filter, MapPin, SearchX, Truck, X } from 'lucide-react'; +import { useAuth } from '../../../../auth/useAuth'; +import { InventoryStatusCounts } from './InventoryStatusCounts'; +import { inventorySubtotalSelection } from './inventory-presentation'; +import { Filter } from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; import { InventoryDesktopTable } from './InventoryDesktopTable'; import { InventoryMobileList } from './InventoryMobileList'; -import { inventoryGroups, inventorySelection, inventoryTotal } from './inventory-presentation'; -import type { InventoryFilters } from '../../model'; import type { InventoryStatisticsSectionProps } from './types'; -import './inventory-statistics.css'; -const emptyFilters: InventoryFilters = { region: '', city: '', brand: '', type: '', model: '' }; -const filterLabels: Record = { region: '区域', city: '城市', brand: '品牌', type: '车型', model: '型号 / 批次' }; - -export function InventoryStatisticsSection(props: InventoryStatisticsSectionProps) { - const { - inventoryFilters: filters, setInventoryFilters: setFilters, - draftInventoryFilters: draft, setDraftInventoryFilters: setDraft, - isInventoryFilterOpen: filterOpen, setIsInventoryFilterOpen: setFilterOpen, - filteredInventoryStats: rows, inventoryTab: tab, setInventoryTab: setTab, - setVehicleSelection: select, - } = props; - const id = useId(); - const filterTrigger = useRef(null); - const [sort, setSort] = useState<'default' | 'quantity'>('default'); - const total = inventoryTotal(rows); - const abnormal = rows.reduce((sum, row) => sum + (row.abnormal || 0), 0); - const pending = rows.reduce((sum, row) => sum + (row.pending || 0), 0); - const activeFilters = (Object.keys(filters) as (keyof InventoryFilters)[]).filter(key => filters[key]); - const groups = inventoryGroups(tab === 'region' ? props.inventoryByRegion : props.inventoryByModel, sort, tab); - const expanded = tab === 'region' ? props.expandedInventoryRegions : props.expandedInventoryTypes; - const toggle = tab === 'region' ? props.toggleInventoryRegion : props.toggleInventoryType; - const allExpanded = groups.length > 0 && groups.every(group => expanded.has(group.name)); - const closeFilters = () => { setFilterOpen(false); filterTrigger.current?.focus(); }; - const resetFilters = () => { setFilters({ ...emptyFilters }); setDraft({ ...emptyFilters }); }; - const options = { - region: props.uniqueInventoryRegions, city: props.uniqueInventoryCities, - brand: props.uniqueInventoryBrands, type: props.uniqueInventoryTypes, - model: props.uniqueInventoryModelsForType, +export function InventoryStatisticsSection({ + inventoryFilters, + setInventoryFilters, + draftInventoryFilters, + setDraftInventoryFilters, + isInventoryFilterOpen, + setIsInventoryFilterOpen, + uniqueInventoryRegions, + uniqueInventoryCities, + uniqueInventoryBrands, + uniqueInventoryTypes, + uniqueInventoryModelsForType, + filteredInventoryStats, + inventoryTab, + setInventoryTab, + inventoryByRegion, + expandedInventoryRegions, + toggleInventoryRegion, + inventoryByModel, + expandedInventoryTypes, + toggleInventoryType, + setVehicleSelection: setShowPlateNumbers, +}: InventoryStatisticsSectionProps) { + const { preview } = useAuth(); + const listProps = { + inventoryTab, + inventoryByRegion, + expandedInventoryRegions, + toggleInventoryRegion, + inventoryByModel, + expandedInventoryTypes, + toggleInventoryType, + setVehicleSelection: setShowPlateNumbers, }; - const listProps = { groups, tab, expanded, toggle, total, filters, select }; return ( -
-
-
- -

库存统计

按区域、车型核对库存分布

+
+
+
+
+
+

库存统计

+ {preview ? '公共库存汇总 · 明细按当前用户权限查看' : '实时更新库存数据'} +
- -
-
- - - +
+
setShowPlateNumbers(inventorySubtotalSelection(filteredInventoryStats, '库存总数'))}> + 总库存数据 + + {filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)} + 台 + +
+ +
+ + + + {isInventoryFilterOpen && ( + <> + +
+

库存统计 - 数据筛选

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + )} +
+
+
-
{new Set(rows.map(row => row.region)).size} 个区域 · {new Set(rows.map(row => row.city)).size} 个城市待交车、异动已包含在库存总数中
- {filterOpen &&
{ if (event.key === 'Escape') { event.preventDefault(); closeFilters(); } }} - onSubmit={event => { event.preventDefault(); setFilters({ ...draft }); closeFilters(); }}> -
筛选库存
-
- {(Object.keys(filterLabels) as (keyof InventoryFilters)[]).map(key => )} + {filteredInventoryStats.some(row => (row.pending ?? 0) > 0 || (row.abnormal ?? 0) > 0) && ( +
+
-
- - - -
- } + )} - {activeFilters.length > 0 &&
- 已筛选 - {activeFilters.map(key => )} - -
} - -
-
- - -
-
- - +
- {groups.length ? <> : -
} -
共 {groups.length} 个{tab === 'region' ? '区域' : '车型'} · {rows.length} 条记录 · {total.toLocaleString()} 台库存占比基于当前筛选结果 · 单位:台
-
+ {/* Active Filters Bar */} + {(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && ( +
+ {inventoryFilters.region && ( + + 区域: {inventoryFilters.region} + + + )} + {inventoryFilters.city && ( + + 城市: {inventoryFilters.city} + + + )} + {inventoryFilters.brand && ( + + 品牌: {inventoryFilters.brand} + + + )} + {inventoryFilters.type && ( + + 车型: {inventoryFilters.type} + + + )} + {inventoryFilters.model && ( + + 批次: {inventoryFilters.model} + + + )} + +
+ )} + + {/* Desktop View Table */} + + + {/* Mobile View */} + +
); } diff --git a/src/modules/assets/components/overview/InventoryStatusCounts.tsx b/src/modules/assets/components/overview/InventoryStatusCounts.tsx index 3715988..0af87c3 100644 --- a/src/modules/assets/components/overview/InventoryStatusCounts.tsx +++ b/src/modules/assets/components/overview/InventoryStatusCounts.tsx @@ -1,12 +1,17 @@ import type { RegionalInventoryStats } from '../../types'; +import type { VehicleModalSelection } from '../../model'; +import { inventorySubtotalSelection } from './inventory-presentation'; /** Status counts are subsets of these filtered inventory rows, never extra stock. */ -export function InventoryStatusCounts({ rows }: { rows: RegionalInventoryStats[] }) { +export function InventoryStatusCounts({ rows, onSelect }: { rows: RegionalInventoryStats[]; onSelect: (selection: VehicleModalSelection) => void }) { const abnormal = rows.reduce((sum, row) => sum + (row.abnormal ?? 0), 0); const pending = rows.reduce((sum, row) => sum + (row.pending ?? 0), 0); if (abnormal === 0 && pending === 0) return null; return - {abnormal > 0 && 其中异动 {abnormal} 台} - {pending > 0 && 其中待交车 {pending} 台} + {abnormal > 0 && } + {pending > 0 && 待交车 {pending} 台} ; } diff --git a/src/modules/assets/components/overview/SummaryMetrics.tsx b/src/modules/assets/components/overview/SummaryMetrics.tsx index e87a2ea..00a925e 100644 --- a/src/modules/assets/components/overview/SummaryMetrics.tsx +++ b/src/modules/assets/components/overview/SummaryMetrics.tsx @@ -52,11 +52,11 @@ export function SummaryMetrics({
diff --git a/src/modules/assets/components/overview/inventory-presentation.test.ts b/src/modules/assets/components/overview/inventory-presentation.test.ts index 22ff92a..97053d1 100644 --- a/src/modules/assets/components/overview/inventory-presentation.test.ts +++ b/src/modules/assets/components/overview/inventory-presentation.test.ts @@ -12,6 +12,19 @@ import { fetchVehicleList } from '../../api'; const filters = { region: '华东', city: '上海', brand: '品牌甲', type: '18T', model: '批次一' }; +test('abnormal inventory drill preserves exact row keys and intersects the abnormal category and company', () => { + const rows = [{ ...filters, batch: '', quantity: 10, abnormal: 1 }]; + const selection = inventorySubtotalSelection(rows, rows[0].model, 'Abnormal'); + const request = buildVehicleModalRequest(selection, '公司甲'); + assert.equal(request.kind, 'vehicles'); + if (request.kind !== 'vehicles') throw new Error('wrong endpoint'); + assert.deepEqual(request.params.inventoryKeys, rows.map(inventoryKey)); + assert.equal(request.params.category, 'Abnormal'); + assert.equal(request.params.subject, '公司甲'); + assert.match(selection.scopeLabel!, /异动 1 台$/); + assert.ok(selection.scopeLabel!.includes('批次一')); +}); + test('mobile region navigation shows only cities then types, preserving totals and exact leaf scope', () => { const rows = [ { ...filters, batch: '', quantity: 9 }, @@ -115,10 +128,10 @@ test('mobile pages cover every record once and clamp after a filter reduces the test('inventory status hints show nonzero subset counts and hide zeros', () => { const row = { ...filters, batch: '', quantity: 9, pending: 2, abnormal: 1 }; - const html = renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [row] })); - assert.match(html, /其中待交车 2<\/strong> 台/); - assert.match(html, /其中异动 1<\/strong> 台/); - assert.equal(renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [{ ...row, pending: 0, abnormal: 0 }] })), ''); + const html = renderToStaticMarkup(createElement(InventoryStatusCounts, { onSelect: () => {}, rows: [row] })); + assert.match(html, /待交车 2<\/strong> 台/); + assert.match(html, /异动 1<\/strong> 台/); + assert.equal(renderToStaticMarkup(createElement(InventoryStatusCounts, { onSelect: () => {}, rows: [{ ...row, pending: 0, abnormal: 0 }] })), ''); }); test('filtered inventory totals retain all filters in the vehicle request', () => { diff --git a/src/modules/assets/components/overview/inventory-presentation.ts b/src/modules/assets/components/overview/inventory-presentation.ts index df52d8a..bd72c70 100644 --- a/src/modules/assets/components/overview/inventory-presentation.ts +++ b/src/modules/assets/components/overview/inventory-presentation.ts @@ -53,13 +53,15 @@ export function inventorySelection(filters: InventoryFilters, title: string, ove }; } -export function inventorySubtotalSelection(rows: RegionalInventoryStats[], label: string): VehicleModalSelection { +export function inventorySubtotalSelection(rows: RegionalInventoryStats[], label: string, category: 'Inventory' | 'Abnormal' = 'Inventory'): VehicleModalSelection { const common = (key: 'region' | 'city' | 'brand' | 'type') => rows.length && rows.every(row => row[key] === rows[0][key]) ? rows[0][key] : ''; const scope = [...new Set([common('region'), common('city'), common('brand'), common('type'), label].filter(Boolean))].join(' · '); return { - batch: 'All', model: 'All', location: 'All', source: 'asset', category: 'Inventory', + batch: 'All', model: 'All', location: 'All', source: 'asset', category, inventoryKeys: [...new Set(rows.map(inventoryKey))], - title: `库存统计 · ${scope}`, scopeLabel: `${scope} · 合计 ${inventoryTotal(rows)} 台`, + title: `库存统计 · ${scope}`, scopeLabel: category === 'Abnormal' + ? `${scope} · 异动 ${rows.reduce((sum, row) => sum + (row.abnormal ?? 0), 0)} 台` + : `${scope} · 合计 ${inventoryTotal(rows)} 台`, }; } diff --git a/src/modules/assets/dimension-layout.test.ts b/src/modules/assets/dimension-layout.test.ts new file mode 100644 index 0000000..e38c129 --- /dev/null +++ b/src/modules/assets/dimension-layout.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { dimensionChildCounts, dimensionCountUnit, dimensionParentTotals, dimensionRowSpans, orderDimensionGroups, visibleDimensionRowSpans } from './dimension-layout'; + +type Row = { values: string[]; count: number }; +const values = (row: Row) => row.values; +const count = (row: Row) => row.count; + +test('child counts deduplicate immediate children rather than counting leaf rows or vehicles', () => { + const rows: Row[] = [ + { values: ['负责人甲', '客户一', '18T'], count: 29 }, + { values: ['负责人甲', '客户一', '49T'], count: 17 }, + { values: ['负责人甲', '客户二', '18T'], count: 80 }, + { values: ['负责人乙', '客户一', '18T'], count: 9 }, + ]; + const counts = dimensionChildCounts(rows, 3, values); + assert.deepEqual(counts, [[2, 2], [2, 2], [2, 1], [1, 1]]); + assert.deepEqual(counts.slice(2, 3), [[2, 1]], 'continuation pages keep the full child count'); + assert.deepEqual(dimensionChildCounts(rows.filter(row => row.values[1] === '客户一'), 3, values), [[1, 2], [1, 2], [1, 1]]); + assert.deepEqual(dimensionChildCounts(rows, 1, values), [[], [], [], []]); + assert.deepEqual(['customer', 'city', 'type', 'manager', 'date'].map(dimensionCountUnit), ['家', '个', '种', '位', '天']); +}); + +test('parent subtotals include all descendants and separate identical child labels under different parents', () => { + const rows: Row[] = [ + { values: ['负责人甲', '客户一', '18T'], count: 29 }, + { values: ['负责人甲', '客户一', '49T'], count: 17 }, + { values: ['负责人甲', '客户二', '18T'], count: 80 }, + { values: ['负责人乙', '客户一', '18T'], count: 9 }, + ]; + const totals = dimensionParentTotals(rows, 3, values, count); + assert.deepEqual(totals, [[126, 46], [126, 46], [126, 80], [9, 9]]); + // A continuation page still displays 126, not the 80 vehicles on that page. + assert.deepEqual(totals.slice(2, 3), [[126, 80]]); + assert.deepEqual(dimensionParentTotals(rows.filter(row => row.values[1] === '客户一'), 3, values, count), [[46, 46], [46, 46], [9, 9]]); + assert.deepEqual(dimensionParentTotals(rows, 1, values, count), [[], [], [], []]); + assert.deepEqual(dimensionParentTotals([], 3, values, count), []); +}); + +test('merged layout ranks parent totals and keeps their children contiguous without changing leaf counts', () => { + const rows: Row[] = [ + { values: ['华东', '租赁'], count: 6 }, + { values: ['华南', '租赁'], count: 10 }, + { values: ['华东', '在库'], count: 5 }, + ]; + const ordered = orderDimensionGroups(rows, 2, values, count); + assert.deepEqual(ordered.map(values), [['华东', '租赁'], ['华东', '在库'], ['华南', '租赁']]); + assert.equal(ordered.reduce((sum, row) => sum + row.count, 0), 21); + assert.deepEqual(dimensionRowSpans(ordered, 2, values), [[2, 1], [0, 1], [1, 1]]); + assert.deepEqual(orderDimensionGroups(rows, 2, values, count, 'count-asc').map(count), [10, 5, 6]); + assert.deepEqual(rows.map(count), [6, 10, 5]); +}); + +test('equal child names under different parents never merge, and the final dimension stays visible', () => { + const rows = [ + ['华东', '未标注', '18T'], ['华东', '未标注', '49T'], + ['华南', '未标注', '18T'], ['华南', '未标注', '49T'], + ].map(values => ({ values, count: 1 })); + assert.deepEqual(dimensionRowSpans(rows, 3, values), [[2, 2, 1], [0, 0, 1], [2, 2, 1], [0, 0, 1]]); +}); + +test('a parent split across pages is rendered again at the start of each page', () => { + const rows = ['A', 'B', 'C', 'D'].map(type => ({ values: ['华东', type], count: 1 })); + assert.deepEqual(dimensionRowSpans(rows.slice(0, 3), 2, values), [[3, 1], [0, 1], [0, 1]]); + assert.deepEqual(dimensionRowSpans(rows.slice(3), 2, values), [[1, 1]]); + assert.deepEqual(dimensionRowSpans(rows, 1, values), [[1], [1], [1], [1]]); + assert.deepEqual(dimensionRowSpans([], 3, values), []); +}); + +test('continuous loading grows the original parent spans across batches without repeating parent cells', () => { + const rows = [ + ['华东', '嘉兴', 'A'], ['华东', '嘉兴', 'B'], ['华东', '嘉兴', 'C'], + ['华东', '杭州', 'D'], ['华南', '广州', 'E'], + ].map(parts => ({ values: parts, count: 2 })); + const complete = dimensionRowSpans(rows, 3, values); + assert.deepEqual(visibleDimensionRowSpans(complete, 2), [[2, 2, 1], [0, 0, 1]]); + assert.deepEqual(visibleDimensionRowSpans(complete, 4), [[4, 3, 1], [0, 0, 1], [0, 0, 1], [0, 1, 1]]); + assert.deepEqual(visibleDimensionRowSpans(complete, 40), complete); + assert.deepEqual(visibleDimensionRowSpans(complete, 0), []); + assert.deepEqual(complete, [[4, 3, 1], [0, 0, 1], [0, 0, 1], [0, 1, 1], [1, 1, 1]]); + assert.equal(dimensionParentTotals(rows, 3, values, count)[0][0], 8); + assert.equal(dimensionChildCounts(rows, 3, values)[0][0], 2); +}); + +test('hierarchical label order supports semantic ordering and separator-containing values', () => { + const rows = [ + { values: ['A|B', '30+'], count: 2 }, { values: ['A', 'B|30+'], count: 1 }, + { values: ['A|B', '3-7'], count: 1 }, + ]; + const semantic = ['3-7', '30+']; + const result = orderDimensionGroups(rows, 2, values, count, 'label', (a, b, index) => index === 0 + ? a.values[0].localeCompare(b.values[0]) : semantic.indexOf(a.values[1]) - semantic.indexOf(b.values[1])); + assert.deepEqual(result.map(values), [['A', 'B|30+'], ['A|B', '3-7'], ['A|B', '30+']]); +}); diff --git a/src/modules/assets/dimension-layout.ts b/src/modules/assets/dimension-layout.ts new file mode 100644 index 0000000..ec6801d --- /dev/null +++ b/src/modules/assets/dimension-layout.ts @@ -0,0 +1,97 @@ +type GroupSort = 'count-desc' | 'count-asc' | 'label'; + +export function dimensionCountUnit(dimension: string): string { + if (dimension === 'customer') return '家'; + if (dimension === 'manager') return '位'; + if (dimension === 'date') return '天'; + if (['type', 'model', 'assetStatus', 'assetDisposition', 'operationKind', 'inventoryState', 'abnormalType', 'contract_type'].includes(dimension)) return '种'; + if (dimension === 'abnormalAge') return '组'; + return '个'; +} + +/** Distinct immediate children within each full parent prefix, across all filtered results. */ +export function dimensionChildCounts(rows: readonly T[], dimensionCount: number, values: (row: T) => readonly string[]): number[][] { + const children = new Map>(); + const keys = rows.map(row => { + const parts = values(row); + return Array.from({ length: Math.max(0, dimensionCount - 1) }, (_, depth) => { + const key = JSON.stringify(parts.slice(0, depth + 1)); + const set = children.get(key) ?? new Set(); + set.add(parts[depth + 1]); + children.set(key, set); + return key; + }); + }); + return keys.map(row => row.map(key => children.get(key)!.size)); +} + +/** Use all filtered groups before rendering a prefix so each parent includes every descendant. */ +export function dimensionParentTotals( + rows: readonly T[], + dimensionCount: number, + values: (row: T) => readonly string[], + count: (row: T) => number, +): number[][] { + const keys = rows.map(row => Array.from({ length: Math.max(0, dimensionCount - 1) }, (_, depth) => JSON.stringify(values(row).slice(0, depth + 1)))); + const totals = new Map(); + rows.forEach((row, index) => { + for (const key of keys[index]) totals.set(key, (totals.get(key) || 0) + count(row)); + }); + return keys.map(row => row.map(key => totals.get(key) || 0)); +} + +/** Sort siblings by their combined count, keeping every parent subtree contiguous. */ +export function orderDimensionGroups( + rows: readonly T[], + dimensionCount: number, + values: (row: T) => readonly string[], + count: (row: T) => number, + sort: GroupSort = 'count-desc', + compareAt?: (a: T, b: T, index: number) => number, +): T[] { + const visit = (siblings: readonly T[], depth: number): T[] => { + if (depth >= dimensionCount || siblings.length < 2) return [...siblings]; + const groups = new Map(); + for (const row of siblings) { + const key = values(row)[depth]; + const group = groups.get(key) ?? { rows: [], count: 0 }; + group.rows.push(row); + group.count += count(row); + groups.set(key, group); + } + return [...groups.values()].sort((a, b) => { + const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0; + return difference || (compareAt + ? compareAt(a.rows[0], b.rows[0], depth) + : values(a.rows[0])[depth].localeCompare(values(b.rows[0])[depth], 'zh-CN', { numeric: true })); + }).flatMap(group => visit(group.rows, depth + 1)); + }; + return visit(rows, 0); +} + +/** A child is merged only within its full parent prefix. */ +export function dimensionRowSpans( + rows: readonly T[], + dimensionCount: number, + values: (row: T) => readonly string[], +): number[][] { + const keys = rows.map(values); + const spans = rows.map(() => Array(dimensionCount).fill(1)); + for (let dimension = 0; dimension < dimensionCount - 1; dimension += 1) { + let start = 0; + while (start < rows.length) { + let end = start + 1; + while (end < rows.length && keys[start].slice(0, dimension + 1).every((value, index) => value === keys[end][index])) end += 1; + spans[start][dimension] = end - start; + for (let row = start + 1; row < end; row += 1) spans[row][dimension] = 0; + start = end; + } + } + return spans; +} + +/** Grow parent cells with the rendered prefix while reusing the complete span calculation. */ +export function visibleDimensionRowSpans(spans: readonly (readonly number[])[], visibleCount: number): number[][] { + const count = Math.min(spans.length, Math.max(0, Math.floor(visibleCount))); + return spans.slice(0, count).map((row, index) => row.map(span => Math.min(span, count - index))); +} diff --git a/src/modules/ele/EleImportPage.tsx b/src/modules/ele/EleImportPage.tsx index e1eeaa1..da6ced2 100644 --- a/src/modules/ele/EleImportPage.tsx +++ b/src/modules/ele/EleImportPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { ArrowLeft, Upload, Zap } from 'lucide-react'; -import { fetchJson } from '../../auth/api-client'; +import { fetchJson, fetchAuthenticated } from '../../auth/api-client'; import { useAuth } from '../../auth/useAuth'; import { PageFrame } from '../../components/ui/surface'; import { BatchTable } from './import-page/BatchTable'; @@ -17,17 +17,11 @@ import type { VehicleKindFilter, } from './import-page/types'; -function getJwt(): string | null { - return sessionStorage.getItem('bi_jwt'); -} - async function uploadFile(file: File): Promise { const fd = new FormData(); fd.append('file', file); - const token = getJwt(); - const res = await fetch('/api/ele/import', { + const res = await fetchAuthenticated('/api/ele/import', { method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd, }); const json = await res.json(); diff --git a/src/server/app.ts b/src/server/app.ts index 929fe4c..843492f 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import authRouter from './auth/login.js'; import { authMiddleware } from './auth/middleware.js'; +import { createPreviewRouter, createPreviewMiddleware } from './auth/preview/routes.js'; import eleRouter from './routes/ele/index.js'; import energyRouter from './routes/energy/index.js'; import feedbackRouter from './routes/feedback/index.js'; @@ -25,6 +26,8 @@ export function createApp(): Hono { // 登录接口公开,其余 API 统一经过认证与数据权限检查。 app.route('/api/auth', authRouter); app.use('/api/*', authMiddleware); + app.route('/api/permission-preview', createPreviewRouter()); + app.use('/api/*', createPreviewMiddleware()); app.route('/api/vehicles', vehiclesRouter); app.route('/api/asset-assistant', assetAssistantRouter); diff --git a/src/server/auth/login.ts b/src/server/auth/login.ts index cfa608a..256fd91 100644 --- a/src/server/auth/login.ts +++ b/src/server/auth/login.ts @@ -1,8 +1,9 @@ import { Hono } from 'hono'; import jwt from 'jsonwebtoken'; import pool from '../db.js'; -import type { AuthUser, JwtPayload, PermissionLevel } from './types.js'; -import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js'; +import { identitySchema } from './preview/directory.js'; +import type { AuthUser, JwtPayload } from './types.js'; +import { resolvePermissionLevel } from '../../shared/auth/roles.js'; const app = new Hono(); @@ -42,20 +43,15 @@ app.get('/exchange', async (c) => { const roleNames = userInfo.roles.map(r => r.roleName); // 确定权限级别 - let permissionLevel: PermissionLevel = 'personal'; - if (roleNames.some(r => FULL_ACCESS_ROLES.includes(r))) { - permissionLevel = 'full'; - } else if (roleNames.some(r => DEPT_ACCESS_ROLES.includes(r))) { - permissionLevel = 'department'; - } + const permissionLevel = resolvePermissionLevel(roleNames); // 查询 depCode 对应的部门名称 let depName = ''; if (userInfo.depCode) { try { const [rows] = await pool.execute( - 'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1', - [userInfo.depCode] + `SELECT dep_name FROM ${identitySchema()}.tab_department WHERE dep_code = ? AND org_id = ? AND is_deleted = 0 LIMIT 1`, + [userInfo.depCode, userInfo.orgId] ) as [{ dep_name: string }[], unknown]; depName = rows[0]?.dep_name || ''; } catch (e: any) { diff --git a/src/server/auth/manager-identity.test.ts b/src/server/auth/manager-identity.test.ts new file mode 100644 index 0000000..fd09dce --- /dev/null +++ b/src/server/auth/manager-identity.test.ts @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { verifiedManagerIds } from './manager-identity.js'; +import { filterByPermission } from './permissions.js'; +import type { AuthUser } from './types.js'; +test('only a unique enabled identity mapping extends personal vehicle IDs; ambiguity fails closed', () => { + assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'old', vehicleId: 'new' }]), ['old', 'new']); + assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'old', vehicleId: null }]), ['old']); + assert.deepEqual(verifiedManagerIds('old', []), []); + assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'wrong', vehicleId: 'new' }]), []); + assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'old', vehicleId: 'new' }, { legacyId: 'old', vehicleId: 'other' }]), []); + const user: AuthUser = { userId: 'old', loginName: 'login', userName: '人', depCode: 'D', depName: '部门甲', permissionLevel: 'personal', roles: [], managerIds: ['old', 'new'] }; + const items = [{ managerId: 'new', department: '部门乙' }, { managerId: 'other', department: '部门甲' }]; + assert.deepEqual(filterByPermission(items, user), [items[0]]); + assert.deepEqual(filterByPermission(items, { ...user, managerIds: [] }), []); + assert.deepEqual(filterByPermission(items, { ...user, permissionLevel: 'department' }), [items[1]]); +}); diff --git a/src/server/auth/manager-identity.ts b/src/server/auth/manager-identity.ts new file mode 100644 index 0000000..2ef0086 --- /dev/null +++ b/src/server/auth/manager-identity.ts @@ -0,0 +1,20 @@ +import pool from '../db.js'; + +/** Match login identifiers only, never human names. Ambiguous or disabled identities fail closed. */ +export async function resolveVehicleManagerIds(userId: string, loginName: string): Promise { + const schema = process.env.BI_IDENTITY_SCHEMA || 'lingniu_prod'; + if (!/^[a-zA-Z0-9_]+$/.test(schema)) throw new Error('Invalid identity schema'); + const [rows] = await pool.query(`SELECT CAST(u.id AS CHAR) legacyId, CAST(n.user_id AS CHAR) vehicleId + FROM \`${schema}\`.tab_user u + LEFT JOIN \`ry-cloud\`.sys_user n ON BINARY n.user_name=BINARY u.login_name + AND n.tenant_id=? AND n.del_flag='0' AND n.status='0' + WHERE u.id=? AND BINARY u.login_name=BINARY ? AND u.enabled=1 AND u.is_deleted=0 + AND (SELECT COUNT(*) FROM \`${schema}\`.tab_user duplicate + WHERE BINARY duplicate.login_name=BINARY u.login_name AND duplicate.enabled=1 AND duplicate.is_deleted=0)=1`, + [process.env.BI_VEHICLE_IDENTITY_TENANT_ID || '000000', userId, loginName]); + return verifiedManagerIds(userId, rows); +} +export function verifiedManagerIds(userId: string, rows: { legacyId: string; vehicleId: string | null }[]): string[] { + if (rows.length !== 1 || rows[0].legacyId !== userId) return []; + return [...new Set([userId, ...(rows[0].vehicleId ? [rows[0].vehicleId] : [])])]; +} diff --git a/src/server/auth/middleware.ts b/src/server/auth/middleware.ts index 898fad9..9c0c287 100644 --- a/src/server/auth/middleware.ts +++ b/src/server/auth/middleware.ts @@ -1,5 +1,6 @@ import type { Context, Next } from 'hono'; import jwt from 'jsonwebtoken'; +import { resolveVehicleManagerIds } from './manager-identity.js'; import type { JwtPayload, AuthUser } from './types.js'; const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret'; @@ -41,20 +42,22 @@ export async function authMiddleware(c: Context, next: Next) { const token = authHeader.slice(7); + let payload: JwtPayload; try { - const payload = jwt.verify(token, JWT_SECRET) as JwtPayload; - const user: AuthUser = { - userId: payload.userId, - userName: payload.userName, - loginName: payload.loginName, - depCode: payload.depCode, - depName: payload.depName, - permissionLevel: payload.permissionLevel, - roles: payload.roles ?? [], - }; - c.set('user', user); - return next(); + payload = jwt.verify(token, JWT_SECRET) as JwtPayload; } catch { return c.json({ error: 'Invalid or expired token' }, 401); } + const user: AuthUser = { + userId: payload.userId, userName: payload.userName, loginName: payload.loginName, + depCode: payload.depCode, depName: payload.depName, + permissionLevel: payload.permissionLevel, roles: payload.roles ?? [], + }; + try { + if (user.permissionLevel === 'personal') user.managerIds = await resolveVehicleManagerIds(user.userId, user.loginName); + } catch { + return c.json({ error: '账号数据范围暂不可用,请稍后重试' }, 503); + } + c.set('user', user); + return next(); } diff --git a/src/server/auth/permissions.ts b/src/server/auth/permissions.ts index 68c1e6d..e5e671a 100644 --- a/src/server/auth/permissions.ts +++ b/src/server/auth/permissions.ts @@ -46,6 +46,6 @@ export function filterByPermission( // personal: 仅看自己负责的车辆 (bd = userId) return items.filter(v => { const obj = v as any; - return obj.managerId === user.userId; + return (user.managerIds ?? [user.userId]).includes(obj.managerId); }); } diff --git a/src/server/auth/preview/directory.ts b/src/server/auth/preview/directory.ts new file mode 100644 index 0000000..7ed3bd2 --- /dev/null +++ b/src/server/auth/preview/directory.ts @@ -0,0 +1,61 @@ +import pool from '../../db.js'; +import type { AuthUser, PermissionLevel } from '../types.js'; +import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES, resolvePermissionLevel } from '../../../shared/auth/roles.js'; + +export type DirectoryUser = AuthUser & { orgId: string }; +export interface UserDirectory { + get(userId: string): Promise; + search(orgId: string, query: string, level: PermissionLevel | undefined, page: number): Promise<{ users: AuthUser[]; page: number; hasMore: boolean }>; +} +// This is the same identity source as the existing jump-token login, not the vehicle-system account IDs. +export function identitySchema() { + const name = process.env.BI_IDENTITY_SCHEMA || 'lingniu_prod'; + if (!/^[a-zA-Z0-9_]+$/.test(name)) throw new Error('Invalid identity schema'); + return `\`${name}\``; +} +const publicUser = (user: DirectoryUser): AuthUser => { + const { orgId: _, ...identity } = user; + return identity; +}; +export { publicUser }; +async function readUsers(where: string, values: unknown[], limit: number, offset = 0): Promise { + const db = identitySchema(); + const [rows] = await pool.query(`SELECT CAST(u.id AS CHAR) userId, u.user_name userName, + u.login_name loginName, CAST(u.org_id AS CHAR) orgId, COALESCE(d.dep_code, '') depCode, + COALESCE(d.dep_name, '') depName FROM ${db}.tab_user u + LEFT JOIN ${db}.tab_department d ON d.id=u.dep_id AND d.is_deleted=0 + WHERE u.is_deleted=0 AND u.enabled=1 AND ${where} + ORDER BY u.user_name, u.id LIMIT ? OFFSET ?`, [...values, limit, offset]); + if (!rows.length) return []; + const ids = rows.map(r => r.userId); + const [roleRows] = await pool.query(`SELECT CAST(ur.user_id AS CHAR) userId, r.role_name roleName + FROM ${db}.tab_user_role ur JOIN ${db}.tab_role r ON r.id=ur.role_id AND r.is_deleted=0 + JOIN ${db}.tab_user u ON u.id=ur.user_id AND r.org_id=u.org_id + WHERE ur.user_id IN (${ids.map(() => '?').join(',')})`, ids); + return rows.map(row => { + const roles = [...new Set(roleRows.filter(r => r.userId === row.userId).map(r => r.roleName))].sort(); + return { ...row, roles, permissionLevel: resolvePermissionLevel(roles) }; + }); +} +export const userDirectory: UserDirectory = { + async get(id) { return (await readUsers('u.id=?', [id], 1))[0] || null; }, + async search(orgId, query, level, page) { + const values: unknown[] = [orgId]; + let where = 'u.org_id=?'; + if (query) { + // Escape LIKE wildcards; values are always parameters. + const term = `%${query.replace(/[!%_]/g, '!$&')}%`; + where += " AND (u.user_name LIKE ? ESCAPE '!' OR u.login_name LIKE ? ESCAPE '!' OR d.dep_name LIKE ? ESCAPE '!')"; + values.push(term, term, term); + } + const hasRole = (roles: string[]) => { + values.push(...roles); + return `EXISTS (SELECT 1 FROM ${identitySchema()}.tab_user_role ur JOIN ${identitySchema()}.tab_role r ON r.id=ur.role_id WHERE ur.user_id=u.id AND r.org_id=u.org_id AND r.is_deleted=0 AND r.role_name IN (${roles.map(() => '?').join(',')}))`; + }; + if (level === 'full') where += ` AND ${hasRole(FULL_ACCESS_ROLES)}`; + if (level === 'department') where += ` AND NOT ${hasRole(FULL_ACCESS_ROLES)} AND ${hasRole(DEPT_ACCESS_ROLES)}`; + if (level === 'personal') where += ` AND NOT ${hasRole([...FULL_ACCESS_ROLES, ...DEPT_ACCESS_ROLES])}`; + const users = await readUsers(where, values, 21, (page - 1) * 20); + return { users: users.slice(0, 20).map(publicUser), page, hasMore: users.length > 20 }; + }, +}; diff --git a/src/server/auth/preview/preview.test.ts b/src/server/auth/preview/preview.test.ts new file mode 100644 index 0000000..f86538e --- /dev/null +++ b/src/server/auth/preview/preview.test.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import { createPreviewService, type PreviewAudit } from './service.js'; +import { createPreviewMiddleware, createPreviewRouter } from './routes.js'; +import type { AuthUser } from '../types.js'; +import type { DirectoryUser } from './directory.js'; +import { resolvePermissionLevel } from '../../../shared/auth/roles.js'; +import { filterByPermission } from '../permissions.js'; +import { getSession } from '../../routes/asset-assistant/session.js'; +const identity = (id: string, level: 'full' | 'department' | 'personal' = 'personal'): DirectoryUser => ({ + userId: id, loginName: id, userName: id, orgId: 'org', depCode: 'D1', depName: '部门甲', + roles: level === 'full' ? ['BI-Leader'] : level === 'department' ? ['BI-Leader-Dep'] : [], permissionLevel: level, +}); +function setup() { + const actor = identity('authorized', 'full'); + const users = new Map([actor, identity('personal'), identity('department', 'department'), identity('leader', 'full'), { ...identity('foreign'), orgId: 'another' }].map(u => [u.userId, u])); + const allowed = new Set([actor.userId]); + const events: PreviewAudit[] = []; + let now = 1000; + const service = createPreviewService({ managerIds: async id => [id], allowedIds: () => allowed, now: () => now, + audit: async event => { events.push(event); }, + directory: { get: async id => users.get(id) || null, search: async (org, q, level, page) => ({ users: [...users.values()].filter(u => u.orgId === org && u.userName.includes(q) && (!level || level === u.permissionLevel)), page, hasMore: false }) }, + }); + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { c.set('user', users.get(c.req.header('Test-Actor') || '') || identity('outsider')); await next(); }); + app.route('/api/permission-preview', createPreviewRouter(service)); + app.use('*', createPreviewMiddleware(service)); + app.all('*', c => c.json({ user: c.get('user'), visible: filterByPermission([ + { id: 1, department: '部门甲', managerId: 'personal' }, { id: 2, department: '部门甲', managerId: 'colleague' }, { id: 3, department: '部门乙', managerId: 'other' }, + ], c.get('user')) })); + const request = (path: string, token?: string, method = 'GET', body?: unknown, as = actor.userId) => app.request(path, { method, + headers: { 'Test-Actor': as, ...(token ? { 'X-BI-Preview': token } : {}), 'Content-Type': 'application/json' }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + return { actor, users, allowed, events, service, request, advance: () => { now += 30 * 60 * 1000; } }; +} +test('login and preview share role precedence', () => { + assert.equal(resolvePermissionLevel([]), 'personal'); + assert.equal(resolvePermissionLevel(['BI-Leader-Dep']), 'department'); + for (const role of ['BI-Leader', '所有权限', '数智中心']) assert.equal(resolvePermissionLevel(['BI-Leader-Dep', role]), 'full'); +}); +test('only explicitly authorized actors can search or start; local bypass and full role alone never grant preview', async () => { + const s = setup(); + for (const user of ['outsider', 'leader', 'dev-local']) { + assert.equal((await s.request('/api/permission-preview/context', undefined, 'GET', undefined, user)).status, 200); + assert.equal((await (await s.request('/api/permission-preview/context', undefined, 'GET', undefined, user)).json()).canPreview, false); + assert.equal((await s.request('/api/permission-preview/users', undefined, 'GET', undefined, user)).status, 403); + assert.equal((await s.request('/api/permission-preview/start', undefined, 'POST', { userId: 'personal' }, user)).status, 403); + } + assert.equal((await s.request('/api/permission-preview/start', undefined, 'POST', { userId: 'foreign' })).status, 403); + assert.equal((await s.request('/api/permission-preview/start', undefined, 'POST', { userId: 'personal', permissionLevel: 'full' })).status, 400); +}); +test('the actual target identity is applied to list, aggregate and detail queries; actor rights are never unioned', async () => { + const s = setup(); + for (const [id, expected] of [['personal', [1]], ['department', [1, 2]], ['leader', [1, 2, 3]]] as const) { + const { previewToken } = await s.service.start(s.actor, id); + for (const [path, method] of [['/api/vehicles/list', 'GET'], ['/api/vehicles/analysis', 'POST'], ['/api/vehicles/analysis/details', 'POST']]) { + const response = await s.request(path, previewToken, method); + assert.equal(response.status, 200); + const data = await response.json(); + assert.deepEqual(data.visible.map((v: { id: number }) => v.id), expected); + assert.equal(data.user.userId, id); + assert.equal(data.user.preview.actorId, s.actor.userId); + assert.equal(response.headers.get('cache-control'), 'no-store'); + } + } + assert.equal((await (await s.request('/api/vehicles/list')).json()).user.userId, s.actor.userId); +}); +test('user search returns no directory list without keywords, while partial keywords still match', async () => { + const s = setup(); + for (const suffix of ['', '?q=%20%20', '?level=full']) { + const res = await s.request(`/api/permission-preview/users${suffix}`); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { users: [], page: 1, hasMore: false }); + } + const res = await s.request('/api/permission-preview/users?q=erson'); + assert.deepEqual((await res.json()).users.map((u: AuthUser) => u.userId), ['personal']); +}); +test('tokens are actor-bound, revocable, expiring, and fail closed for deleted users or changed organizations', async () => { + const s = setup(); + const { previewToken } = await s.service.start(s.actor, 'personal'); + assert.equal((await s.request('/api/vehicles/list', previewToken, 'GET', undefined, 'leader')).status, 403); + assert.equal((await s.request('/api/vehicles/list', 'forged')).status, 410); + s.allowed.clear(); + assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 403); + s.allowed.add(s.actor.userId); + s.users.get('personal')!.orgId = 'foreign'; + assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 403); + s.users.get('personal')!.orgId = 'org'; + s.users.get('personal')!.permissionLevel = 'department'; + assert.equal((await (await s.request('/api/vehicles/list', previewToken)).json()).visible.length, 2); + s.users.delete('personal'); + assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 403); + s.advance(); + assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 410); +}); +test('all writes, side-effect GETs and unrecognized POSTs are blocked; audited read POSTs and isolated session cleanup work', async () => { + const s = setup(), { previewToken } = await s.service.start(s.actor, 'personal'); + for (const [path, method] of [['/api/feedback/upload', 'POST'], ['/api/feedback/submit', 'POST'], ['/api/feedback/1', 'PATCH'], ['/api/ele/import', 'POST'], ['/api/scheduling/notify', 'POST'], ['/api/asset-assistant/feedback', 'POST'], ['/api/vehicles/refresh', 'GET'], ['/api/vehicles/debug', 'HEAD'], ['/api/new-write-route', 'POST']]) { + const res = await s.request(path, previewToken, method); + assert.equal(res.status, 403, path); + if (method !== 'HEAD') assert.equal((await res.json()).code, 'PREVIEW_READ_ONLY'); + } + assert.equal((await s.request('/api/asset-assistant/ask', previewToken, 'POST')).status, 200); + assert.equal((await s.request('/api/asset-assistant/session/test', previewToken, 'DELETE')).status, 200); + assert.ok(s.events.some(e => e.event === 'blocked-write')); + assert.ok(s.events.every(e => !JSON.stringify(e).includes(previewToken))); + await s.request('/api/permission-preview/stop', previewToken, 'POST'); + assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 410); + assert.ok(s.events.some(e => e.event === 'stop')); +}); +test('AI sessions are isolated between real target, actor and every preview session', () => { + const real = identity('target'); + const a = { ...real, preview: { actorId: 'actor-a', sessionId: 'one' } }; + const session = getSession(a, null); + for (const user of [real, { ...a, preview: { actorId: 'actor-b', sessionId: 'one' } }, { ...a, preview: { actorId: 'actor-a', sessionId: 'two' } }]) + assert.throws(() => getSession(user, null, session.id), /会话已过期/); + assert.equal(getSession(a, null, session.id).id, session.id); +}); +test('audit failure never grants a session or returns target data', async () => { + const actor = identity('actor'); + const service = createPreviewService({ managerIds: async id => [id], allowedIds: () => new Set(['actor']), directory: { get: async id => identity(id), search: async () => ({ users: [], page: 1, hasMore: false }) }, audit: async () => { throw new Error('disk unavailable'); } }); + await assert.rejects(service.start(actor, 'target'), /disk unavailable/); +}); + +test('local preview requires both explicit dev switches, an authorized organization anchor, and never works in production', async () => { + const previous = { NODE_ENV: process.env.NODE_ENV, DEV_BYPASS_AUTH: process.env.DEV_BYPASS_AUTH, DEV_PERMISSION_PREVIEW: process.env.DEV_PERMISSION_PREVIEW, DEV_PERMISSION_PREVIEW_USER_ID: process.env.DEV_PERMISSION_PREVIEW_USER_ID }; + try { + const actor = identity('dev-local', 'full'), anchor = identity('anchor'); + const service = createPreviewService({ allowedIds: () => new Set(['anchor']), directory: { get: async id => id === 'anchor' ? anchor : identity(id), search: async () => ({ users: [], page: 1, hasMore: false }) }, audit: async () => {}, managerIds: async id => [id] }); + process.env.DEV_PERMISSION_PREVIEW_USER_ID = 'anchor'; + process.env.NODE_ENV = 'development'; process.env.DEV_BYPASS_AUTH = '1'; process.env.DEV_PERMISSION_PREVIEW = '0'; + assert.equal((await service.context(actor)).canPreview, false); + process.env.DEV_PERMISSION_PREVIEW = '1'; + assert.equal((await service.context(actor)).canPreview, true); + const session = await service.start(actor, 'target'); + assert.equal((await service.current(actor, session.previewToken)).user.userId, 'target'); + process.env.NODE_ENV = 'production'; + assert.equal((await service.context(actor)).canPreview, false); + await assert.rejects(service.current(actor, session.previewToken), /没有权限/); + process.env.NODE_ENV = 'development'; process.env.DEV_BYPASS_AUTH = '0'; + assert.equal((await service.context(actor)).canPreview, false); + process.env.DEV_BYPASS_AUTH = '1'; process.env.DEV_PERMISSION_PREVIEW_USER_ID = 'unauthorized'; + assert.equal((await service.context(actor)).canPreview, false); + } finally { + for (const [key,value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } + } +}); diff --git a/src/server/auth/preview/read-only-pages.test.ts b/src/server/auth/preview/read-only-pages.test.ts new file mode 100644 index 0000000..0b6d1d8 --- /dev/null +++ b/src/server/auth/preview/read-only-pages.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import pool from '../../db.js'; +import type { AuthUser } from '../types.js'; +import assistantRouter from '../../routes/asset-assistant/index.js'; +import feedbackRouter from '../../routes/feedback/index.js'; +import eleRouter from '../../routes/ele/index.js'; + +function previewApp() { + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { + c.set('user', { userId: 'target', userName: 'Target', loginName: 'target', depCode: '', depName: '', + roles: ['BI-Leader'], permissionLevel: 'full', preview: { actorId: 'actor', sessionId: 'preview' } }); + await next(); + }); + app.route('/assistant', assistantRouter); + app.route('/feedback', feedbackRouter); + app.route('/ele', eleRouter); + return app; +} +const paths = ['/assistant/gaps', '/feedback/mine', '/feedback/list', '/ele/list', '/ele/batches', '/ele/aggregate']; + +test('preview gaps, feedback and charge pages perform only SELECT without schema initialization or retention writes', async (t) => { + const statements: { sql: string; values: unknown }[] = []; + t.mock.method(pool, 'query', async (sql: string, values: unknown) => { + statements.push({ sql, values }); + return [[], []]; + }); + const app = previewApp(); + for (const path of paths) assert.equal((await app.request(path)).status, 200, path); + assert.ok(statements.length >= paths.length); + assert.ok(statements.every(({ sql }) => /^\s*SELECT\b/.test(sql))); + const gap = statements.find(({ sql }) => sql.includes('FROM bi_asset_ai_gaps'))!; + assert.match(gap.sql, /last_seen >= DATE_SUB/); + assert.deepEqual(gap.values, ['target']); + const feedback = statements.find(({ sql }) => sql.includes('WHERE user_id = ?'))!; + assert.deepEqual(feedback.values, ['target']); +}); + +test('preview pages report missing storage as 503 without attempting to repair or initialize it', async (t) => { + const statements: string[] = []; + t.mock.method(pool, 'query', async (sql: string) => { + statements.push(sql); + throw Object.assign(new Error('missing'), { code: 'ER_NO_SUCH_TABLE' }); + }); + const app = previewApp(); + for (const path of paths) { + const response = await app.request(path); + assert.equal(response.status, 503, path); + const body = await response.json(); + assert.ok(body.error || body.message, path); + } + assert.equal(statements.length, paths.length); + assert.ok(statements.every(sql => /^\s*SELECT\b/.test(sql))); +}); diff --git a/src/server/auth/preview/routes.ts b/src/server/auth/preview/routes.ts new file mode 100644 index 0000000..bd2024e --- /dev/null +++ b/src/server/auth/preview/routes.ts @@ -0,0 +1,68 @@ +import { Hono, type Context, type Next } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import type { AuthUser, PermissionLevel } from '../types.js'; +import { previewService, PreviewError, type createPreviewService } from './service.js'; + +type Service = ReturnType; +function failure(c: Context, error: unknown) { + if (error instanceof PreviewError) return c.json({ error: error.message, code: error.code }, error.status); + console.error('permission-preview unavailable'); + return c.json({ error: '权限预览暂不可用,请稍后重试', code: 'PREVIEW_UNAVAILABLE' }, 503); +} +export function createPreviewRouter(service: Service = previewService) { + const router = new Hono<{ Variables: { user: AuthUser } }>(); + router.use('*', bodyLimit({ maxSize: 2048 })); + router.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + if (!c.get('user')) return c.json({ error: '请先登录' }, 401); + try { await next(); } catch (e) { return failure(c, e); } + }); + router.get('/context', async c => c.json(await service.context(c.get('user')))); + router.get('/users', async c => { + const q = (c.req.query('q') || '').trim(), level = c.req.query('level') || undefined; + const page = Number(c.req.query('page') || '1'); + if (q.length > 80 || !Number.isSafeInteger(page) || page < 1 || page > 10000 || (level && !['full', 'department', 'personal'].includes(level))) + return c.json({ error: '搜索条件无效' }, 400); + return c.json(await service.search(c.get('user'), q, level as PermissionLevel | undefined, page)); + }); + router.post('/start', async c => { + const body = await c.req.json().catch(() => null); + if (!body || typeof body.userId !== 'string' || !/^[\w-]{1,64}$/.test(body.userId) || Object.keys(body).some(key => key !== 'userId')) + return c.json({ error: '请选择有效用户' }, 400); + return c.json(await service.start(c.get('user'), body.userId)); + }); + router.get('/current', async c => { + const result = await service.current(c.get('user'), c.req.header('X-BI-Preview') || ''); + return c.json({ user: result.user, expiresAt: result.expiresAt }); + }); + router.post('/stop', async c => { + await service.stop(c.get('user'), c.req.header('X-BI-Preview') || ''); + return c.json({ ok: true }); + }); + router.onError((error, c) => failure(c, error)); + return router; +} + +// These POSTs only query data (AI previews suppress gap recording). Everything else is denied by default. +const READ_POSTS = new Set(['/api/vehicles/analysis', '/api/vehicles/analysis/details', '/api/asset-assistant/ask', '/api/asset-assistant/export']); +export function createPreviewMiddleware(service: Service = previewService) { + return async (c: Context, next: Next) => { + const token = c.req.header('X-BI-Preview'); + if (!token) return next(); + const actor = c.get('user') as AuthUser | undefined; + if (!actor) return c.json({ error: '请先登录' }, 401); + c.header('Cache-Control', 'no-store'); + try { + const session = await service.current(actor, token); + const readOnly = (!['/api/vehicles/refresh', '/api/vehicles/debug'].includes(c.req.path) && ['GET', 'HEAD'].includes(c.req.method)) || (c.req.method === 'POST' && READ_POSTS.has(c.req.path)) + || (c.req.method === 'DELETE' && /^\/api\/asset-assistant\/session\/[^/]+$/.test(c.req.path)); + await service.audit({ event: readOnly ? 'read' : 'blocked-write', actorId: actor.userId, targetId: session.user.userId, + sessionId: session.sessionId, method: c.req.method, path: c.req.path, permissionLevel: session.user.permissionLevel }); + if (!readOnly) return c.json({ error: '权限预览为只读模式,请退出预览后操作', code: 'PREVIEW_READ_ONLY' }, 403); + c.set('actor', actor); + c.set('user', { ...session.user, preview: { actorId: actor.userId, sessionId: session.sessionId } } satisfies AuthUser); + } catch (error) { return failure(c, error); } + // Do not catch downstream failures as authentication errors. + return next(); + }; +} diff --git a/src/server/auth/preview/service.ts b/src/server/auth/preview/service.ts new file mode 100644 index 0000000..ef62fdf --- /dev/null +++ b/src/server/auth/preview/service.ts @@ -0,0 +1,99 @@ +import { resolveVehicleManagerIds } from '../manager-identity.js'; +import { randomBytes } from 'node:crypto'; +import { appendFile, mkdir } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import type { AuthUser } from '../types.js'; +import { publicUser, userDirectory, type UserDirectory, type DirectoryUser } from './directory.js'; + +const TTL = 30 * 60 * 1000; +// Verified legacy SSO accounts; override with a comma-separated list of immutable user IDs. +export const DEFAULT_PREVIEW_USER_IDS = [ + '1014807041625047040', // puhongxia (default local preview organization anchor) + '1105261382487539712', // zhanglan + '1116631120763437056', // shishengliang +]; +export function isLocalPreviewEnabled(actor: AuthUser) { + return actor?.userId === 'dev-local' && actor.loginName === 'dev-local' + && process.env.NODE_ENV !== 'production' && process.env.DEV_BYPASS_AUTH === '1' && process.env.DEV_PERMISSION_PREVIEW === '1'; +} +export const configuredPreviewIds = () => new Set((process.env.BI_PERMISSION_PREVIEW_USER_IDS ?? DEFAULT_PREVIEW_USER_IDS.join(',')).split(',').map(s => s.trim()).filter(Boolean)); +export type PreviewAudit = { event: string; actorId: string; targetId?: string; sessionId?: string; method?: string; path?: string; permissionLevel?: string }; +export async function writePreviewAudit(event: PreviewAudit) { + const path = resolve(process.env.BI_PERMISSION_PREVIEW_AUDIT_FILE || 'logs/permission-preview.jsonl'); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await appendFile(path, JSON.stringify({ time: new Date().toISOString(), ...event }) + '\n', { mode: 0o600 }); +} +export class PreviewError extends Error { + constructor(public code: string, message: string, public status: 400 | 403 | 410 | 503 = 403) { super(message); } +} +type Session = { actorId: string; targetId: string; id: string; expires: number }; +export function createPreviewService(options: { directory?: UserDirectory; allowedIds?: () => Set; audit?: (event: PreviewAudit) => Promise; now?: () => number; managerIds?: (userId: string, loginName: string) => Promise } = {}) { + const directory = options.directory || userDirectory; + const managerIds = options.managerIds || resolveVehicleManagerIds; + const allowedIds = options.allowedIds || configuredPreviewIds; + const audit = options.audit || writePreviewAudit; + const now = options.now || Date.now; + const sessions = new Map(); + const clean = () => { for (const [token, s] of sessions) if (s.expires <= now()) sessions.delete(token); }; + async function authorized(actor: AuthUser): Promise { + if (isLocalPreviewEnabled(actor)) { + const anchorId = process.env.DEV_PERMISSION_PREVIEW_USER_ID || DEFAULT_PREVIEW_USER_IDS[0]; + if (!allowedIds().has(anchorId)) throw new PreviewError('PREVIEW_REVOKED', '本地预览未配置有效授权范围'); + const anchor = await directory.get(anchorId); + if (!anchor) throw new PreviewError('PREVIEW_REVOKED', '本地预览组织范围不可用'); + return anchor; + } + if (!actor || !allowedIds().has(actor.userId)) throw new PreviewError('PREVIEW_REVOKED', '当前账号没有权限视角预览授权'); + const current = await directory.get(actor.userId); + if (!current || current.loginName !== actor.loginName) throw new PreviewError('PREVIEW_REVOKED', '预览授权账号已失效,请重新登录'); + return current; + } + async function targetFor(actor: DirectoryUser, targetId: string) { + const target = await directory.get(targetId); + if (!target || !actor.orgId || target.orgId !== actor.orgId) throw new PreviewError('PREVIEW_TARGET_UNAVAILABLE', '该用户已停用或不在可预览的组织内'); + return target.permissionLevel === 'personal' ? { ...target, managerIds: await managerIds(target.userId, target.loginName) } : target; + } + return { + async context(actor: AuthUser) { + if (!actor || (!isLocalPreviewEnabled(actor) && !allowedIds().has(actor.userId))) return { canPreview: false, actor }; + try { await authorized(actor); return { canPreview: true, actor }; } + catch (error) { if (error instanceof PreviewError) return { canPreview: false, actor }; throw error; } + }, + async search(actor: AuthUser, query: string, level: 'full' | 'department' | 'personal' | undefined, page: number) { + const identity = await authorized(actor); + const term = query.trim(); + if (!term) return { users: [], page: 1, hasMore: false }; + return directory.search(identity.orgId, term, level, page); + }, + async start(actor: AuthUser, targetId: string) { + const identity = await authorized(actor), target = await targetFor(identity, targetId); + clean(); + if (sessions.size >= 1000) throw new PreviewError('PREVIEW_BUSY', '预览会话已满,请稍后重试', 503); + const token = randomBytes(32).toString('base64url'); + const s = { actorId: actor.userId, targetId, id: randomBytes(12).toString('hex'), expires: now() + TTL }; + await audit({ event: 'start', actorId: actor.userId, targetId, sessionId: s.id, permissionLevel: target.permissionLevel }); + // Bound per-user sessions, preserving a small number of independent tabs. + const own = [...sessions].filter(([, value]) => value.actorId === actor.userId); + while (own.length >= 5) sessions.delete(own.shift()![0]); + sessions.set(token, s); + return { previewToken: token, user: publicUser(target), expiresAt: new Date(s.expires).toISOString() }; + }, + async current(actor: AuthUser, token: string) { + const s = sessions.get(token); + if (!s || s.expires <= now()) { sessions.delete(token); throw new PreviewError('PREVIEW_EXPIRED', '预览已过期,请重新选择用户', 410); } + if (s.actorId !== actor.userId) throw new PreviewError('PREVIEW_INVALID', '预览会话与登录账号不匹配'); + const identity = await authorized(actor), target = await targetFor(identity, s.targetId); + return { user: publicUser(target), expiresAt: new Date(s.expires).toISOString(), sessionId: s.id }; + }, + async stop(actor: AuthUser, token: string) { + const s = sessions.get(token); + if (s && s.actorId !== actor.userId) throw new PreviewError('PREVIEW_INVALID', '预览会话与登录账号不匹配'); + if (s) { + sessions.delete(token); + await audit({ event: 'stop', actorId: actor.userId, targetId: s.targetId, sessionId: s.id }); + } + }, + audit, + }; +} +export const previewService = createPreviewService(); diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index 20112bb..bc96b35 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -1,6 +1,10 @@ export type PermissionLevel = 'full' | 'department' | 'personal'; export interface AuthUser { + /** Server-only preview identity; never accepted from client input. */ + preview?: { actorId: string; sessionId: string }; + /** Verified legacy/current vehicle identities, resolved only on the server. */ + managerIds?: string[]; userId: string; userName: string; loginName: string; diff --git a/src/server/routes/asset-assistant/gaps.ts b/src/server/routes/asset-assistant/gaps.ts index a1b2820..10929f3 100644 --- a/src/server/routes/asset-assistant/gaps.ts +++ b/src/server/routes/asset-assistant/gaps.ts @@ -58,13 +58,15 @@ export const gapStore = { [userId, fingerprint, demand, reason], ); }, - async list(userId: string | null): Promise { - await ensure(); - await purge(); + async list(userId: string | null, options: { readOnly?: boolean } = {}): Promise { + if (!options.readOnly) { + await ensure(); + await purge(); + } const [rows] = await pool.query( `SELECT id,demand,reason,status,occurrences, DATE_FORMAT(first_seen,'%Y-%m-%dT%H:%i:%sZ') firstSeen, DATE_FORMAT(last_seen,'%Y-%m-%dT%H:%i:%sZ') lastSeen - FROM bi_asset_ai_gaps ${userId ? "WHERE user_id = ?" : ""} ORDER BY last_seen DESC LIMIT 200`, + FROM bi_asset_ai_gaps WHERE ${userId ? "user_id = ? AND " : ""}last_seen >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 90 DAY) ORDER BY last_seen DESC LIMIT 200`, userId ? [userId] : [], ); return rows as DemandGap[]; diff --git a/src/server/routes/asset-assistant/index.ts b/src/server/routes/asset-assistant/index.ts index bf69001..9d7c53e 100644 --- a/src/server/routes/asset-assistant/index.ts +++ b/src/server/routes/asset-assistant/index.ts @@ -59,6 +59,7 @@ router.get("/gaps", async (c) => { return c.json({ items: await gapStore.list( c.req.query("scope") === "all" ? null : user.userId, + { readOnly: !!user.preview }, ), canManage: manage, }); @@ -502,8 +503,10 @@ router.post("/ask", async (c) => { if (unanswered?.length) demand = `${unanswered.map(item => `${item.question}:${item.reason}`).join(";")}(原问题:${demand})`.slice(0, 600); let recorded = false; try { - await gapStore.record(user.userId, demand, gapReason); - recorded = true; + if (!user.preview) { + await gapStore.record(user.userId, demand, gapReason); + recorded = true; + } } catch { /* Report failure explicitly, never pretend it was persisted. */ } @@ -518,8 +521,10 @@ router.post("/ask", async (c) => { if (!controller.signal.aborted) { let recorded = false; try { - await gapStore.record(user.userId, demand, "generation"); - recorded = true; + if (!user.preview) { + await gapStore.record(user.userId, demand, "generation"); + recorded = true; + } } catch { /* No raw error or query payload in logs. */ } diff --git a/src/server/routes/asset-assistant/session.ts b/src/server/routes/asset-assistant/session.ts index 2b5b4f0..97e5796 100644 --- a/src/server/routes/asset-assistant/session.ts +++ b/src/server/routes/asset-assistant/session.ts @@ -40,6 +40,9 @@ const owner = (u: AuthUser) => u.depCode, u.permissionLevel, [...u.roles].sort(), + [...(u.managerIds ?? [u.userId])].sort(), + u.preview?.actorId ?? null, + u.preview?.sessionId ?? null, ]), ) .digest("hex"); diff --git a/src/server/routes/ele/index.ts b/src/server/routes/ele/index.ts index 7801e61..7fc6072 100644 --- a/src/server/routes/ele/index.ts +++ b/src/server/routes/ele/index.ts @@ -2,9 +2,17 @@ import { Hono } from 'hono'; import type { RowDataPacket, ResultSetHeader } from 'mysql2'; import * as XLSX from 'xlsx'; import pool from '../../db.js'; +import type { AuthUser } from '../../auth/types.js'; import { ensureChargeRecordTable } from './migration.js'; -const app = new Hono(); +const app = new Hono<{ Variables: { user: AuthUser } }>(); + +app.onError((error, c) => { + if (c.get('user')?.preview && ['ER_NO_SUCH_TABLE', 'ER_BAD_FIELD_ERROR'].includes((error as { code?: string }).code || '')) + return c.json({ message: '充电数据存储尚未就绪,权限预览不会初始化或更新表结构,请联系管理员。', code: 'PREVIEW_ELE_STORAGE_UNAVAILABLE' }, 503); + console.error(error); + return c.text('Internal Server Error', 500); +}); // 与 xlsx 列名对齐 const COL = { @@ -259,7 +267,7 @@ app.post('/import', async (c) => { // GET /api/ele/list — 分页列表(最新优先) // ========================================================= app.get('/list', async (c) => { - await ensureChargeRecordTable(); + if (!c.get('user')?.preview) await ensureChargeRecordTable(); const page = Math.max(1, Number(c.req.query('page')) || 1); const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50)); const kind = c.req.query('kind') || ''; @@ -306,7 +314,7 @@ app.get('/list', async (c) => { // GET /api/ele/batches — 批次列表 // ========================================================= app.get('/batches', async (c) => { - await ensureChargeRecordTable(); + if (!c.get('user')?.preview) await ensureChargeRecordTable(); const [rows] = await pool.query( `SELECT batch_id, MIN(imported_at) AS imported_at, @@ -327,7 +335,7 @@ app.get('/batches', async (c) => { // GET /api/ele/aggregate — 聚合统计 // ========================================================= app.get('/aggregate', async (c) => { - await ensureChargeRecordTable(); + if (!c.get('user')?.preview) await ensureChargeRecordTable(); // 全量分类汇总 const [overallRows] = await pool.query( `SELECT vehicle_kind, diff --git a/src/server/routes/feedback/index.ts b/src/server/routes/feedback/index.ts index fb779a6..a885a45 100644 --- a/src/server/routes/feedback/index.ts +++ b/src/server/routes/feedback/index.ts @@ -5,7 +5,14 @@ import type { AuthUser } from '../../auth/types.js'; import { canManageFeedback } from '../../auth/types.js'; import { uploadFeedbackImage } from './oss.js'; -const app = new Hono(); +const app = new Hono<{ Variables: { user: AuthUser } }>(); + +app.onError((error, c) => { + if (c.get('user')?.preview && ['ER_NO_SUCH_TABLE', 'ER_BAD_FIELD_ERROR'].includes((error as { code?: string }).code || '')) + return c.json({ message: '反馈存储尚未就绪,权限预览不会初始化或更新表结构,请联系管理员。', code: 'PREVIEW_FEEDBACK_STORAGE_UNAVAILABLE' }, 503); + console.error(error); + return c.text('Internal Server Error', 500); +}); const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); @@ -116,7 +123,7 @@ app.post('/upload', async (c) => { // GET /api/feedback/mine — 当前用户的反馈历史 app.get('/mine', async (c) => { - await ensureTable(); + if (!c.get('user')?.preview) await ensureTable(); const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; if (!user?.userId) return c.json({ items: [] }); const [rows] = await pool.query( @@ -133,7 +140,7 @@ app.get('/mine', async (c) => { // GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限) app.get('/list', async (c) => { - await ensureTable(); + if (!c.get('user')?.preview) await ensureTable(); const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; if (!canManageFeedback(user?.roles)) { return c.json({ ok: false, message: '无权限' }, 403); diff --git a/src/server/routes/mileage/daily-report-preview.test.ts b/src/server/routes/mileage/daily-report-preview.test.ts new file mode 100644 index 0000000..bc10c5f --- /dev/null +++ b/src/server/routes/mileage/daily-report-preview.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import pool from '../../db.js'; +import type { AuthUser } from '../../auth/types.js'; +import dailyReportRouter from './daily-report.js'; +import { DailyReportStorageUnavailable, getDailyReportSnapshot, listDailyReportHistory } from './daily-report-store.js'; + +test('preview report storage reads never initialize or write tables, including missing-table failures', async (t) => { + const statements: string[] = []; + let missing = false; + t.mock.method(pool, 'query', async (sql: string) => { + statements.push(sql); + if (missing) throw Object.assign(new Error('missing'), { code: 'ER_NO_SUCH_TABLE' }); + return [[], []]; + }); + assert.equal(await getDailyReportSnapshot('2026-09-28', { readOnly: true }), null); + assert.deepEqual(await listDailyReportHistory(90, { readOnly: true }), { items: [], latestDate: null }); + missing = true; + await assert.rejects(getDailyReportSnapshot('2026-09-28', { readOnly: true }), DailyReportStorageUnavailable); + await assert.rejects(listDailyReportHistory(90, { readOnly: true }), DailyReportStorageUnavailable); + assert.equal(statements.length, 4); + assert.ok(statements.every(sql => /^SELECT\b/.test(sql))); +}); + +test('both preview report endpoints return an actionable error without DDL when storage is absent', async (t) => { + const statements: string[] = []; + t.mock.method(pool, 'query', async (sql: string) => { + statements.push(sql); + throw Object.assign(new Error('missing'), { code: 'ER_NO_SUCH_TABLE' }); + }); + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { + c.set('user', { userId: 'target', userName: 'Target', loginName: 'target', depCode: '', depName: '', + roles: [], permissionLevel: 'personal', preview: { actorId: 'actor', sessionId: 'preview' } }); + await next(); + }); + app.route('/reports', dailyReportRouter); + for (const path of ['/reports?date=2026-09-28', '/reports/history']) { + const response = await app.request(path); + assert.equal(response.status, 503); + assert.equal((await response.json()).code, 'PREVIEW_REPORT_STORAGE_UNAVAILABLE'); + } + assert.equal(statements.length, 2); + assert.ok(statements.every(sql => /^SELECT\b/.test(sql))); +}); diff --git a/src/server/routes/mileage/daily-report-service.ts b/src/server/routes/mileage/daily-report-service.ts index 7f302bf..f09efc9 100644 --- a/src/server/routes/mileage/daily-report-service.ts +++ b/src/server/routes/mileage/daily-report-service.ts @@ -17,7 +17,7 @@ import { mileageBand, roundMileage, } from './daily-report-model.js'; -import { getDailyReportSnapshot, saveDailyReportSnapshot } from './daily-report-store.js'; +import { getDailyReportSnapshot, saveDailyReportSnapshot, type DailyReportReadOptions } from './daily-report-store.js'; interface AssessmentRow extends RowDataPacket { target_id: number; @@ -286,8 +286,8 @@ export async function generateDailyMileageReport( }); } -export async function getOrGenerateDailyMileageReport(reportDate: string): Promise { - const snapshot = await getDailyReportSnapshot(reportDate); +export async function getOrGenerateDailyMileageReport(reportDate: string, options: DailyReportReadOptions = {}): Promise { + const snapshot = await getDailyReportSnapshot(reportDate, options); if (snapshot) return snapshot; return generateDailyMileageReport(reportDate, 'PREVIEW', 'LIVE'); } diff --git a/src/server/routes/mileage/daily-report-store.ts b/src/server/routes/mileage/daily-report-store.ts index ab54637..81e274f 100644 --- a/src/server/routes/mileage/daily-report-store.ts +++ b/src/server/routes/mileage/daily-report-store.ts @@ -9,6 +9,23 @@ import type { const TABLE = 'lingniu_prod.tab_mileage_daily_report'; +export type DailyReportReadOptions = { readOnly?: boolean }; +export class DailyReportStorageUnavailable extends Error { + constructor() { super('日报归档尚未初始化,权限预览不会创建存储,请联系管理员。'); } +} + +async function readReportRows(sql: string, values: unknown[], options: DailyReportReadOptions) { + if (!options.readOnly) await ensureDailyReportTable(); + try { + const [rows] = await pool.query(sql, values); + return rows; + } catch (error) { + if (options.readOnly && (error as { code?: string })?.code === 'ER_NO_SUCH_TABLE') + throw new DailyReportStorageUnavailable(); + throw error; + } +} + const CREATE_TABLE_SQL = ` CREATE TABLE IF NOT EXISTS ${TABLE} ( id BIGINT NOT NULL AUTO_INCREMENT, @@ -48,11 +65,11 @@ function parsePayload(value: unknown): DailyMileageReport | null { } } -export async function getDailyReportSnapshot(reportDate: string): Promise { - await ensureDailyReportTable(); - const [rows] = await pool.query( +export async function getDailyReportSnapshot(reportDate: string, options: DailyReportReadOptions = {}): Promise { + const rows = await readReportRows( `SELECT report_payload FROM ${TABLE} WHERE report_date = ? LIMIT 1`, [reportDate], + options, ); return parsePayload(rows[0]?.report_payload); } @@ -108,10 +125,9 @@ export async function updateDailyReportSnapshotPayload(report: DailyMileageRepor return result.affectedRows > 0; } -export async function listDailyReportHistory(limit = 90): Promise { - await ensureDailyReportTable(); +export async function listDailyReportHistory(limit = 90, options: DailyReportReadOptions = {}): Promise { const safeLimit = Math.min(366, Math.max(1, limit)); - const [rows] = await pool.query( + const rows = await readReportRows( `SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date, status, source, vehicle_count, operating_count, inventory_count, daily_mileage, day_over_day_rate, @@ -120,6 +136,7 @@ export async function listDailyReportHistory(limit = 90): Promise ({ reportDate: row.report_date, diff --git a/src/server/routes/mileage/daily-report.ts b/src/server/routes/mileage/daily-report.ts index 371898d..c2dc5f5 100644 --- a/src/server/routes/mileage/daily-report.ts +++ b/src/server/routes/mileage/daily-report.ts @@ -1,8 +1,9 @@ import { Hono } from 'hono'; +import type { AuthUser } from '../../auth/types.js'; import { getOrGenerateDailyMileageReport } from './daily-report-service.js'; -import { listDailyReportHistory } from './daily-report-store.js'; +import { DailyReportStorageUnavailable, listDailyReportHistory } from './daily-report-store.js'; -const app = new Hono(); +const app = new Hono<{ Variables: { user: AuthUser } }>(); function isYmd(value: string): boolean { return /^\d{4}-\d{2}-\d{2}$/.test(value); @@ -11,8 +12,10 @@ function isYmd(value: string): boolean { app.get('/history', async (c) => { try { const limit = Number(c.req.query('limit')) || 90; - return c.json(await listDailyReportHistory(limit)); + return c.json(await listDailyReportHistory(limit, { readOnly: !!c.get('user')?.preview })); } catch (error: unknown) { + if (error instanceof DailyReportStorageUnavailable) + return c.json({ message: error.message, code: 'PREVIEW_REPORT_STORAGE_UNAVAILABLE' }, 503); console.error('mileage daily report history error:', error); return c.json({ items: [], latestDate: null }, 500); } @@ -22,8 +25,10 @@ app.get('/', async (c) => { const date = c.req.query('date') || ''; if (!isYmd(date)) return c.json({ message: 'date 必须为 YYYY-MM-DD' }, 400); try { - return c.json(await getOrGenerateDailyMileageReport(date)); + return c.json(await getOrGenerateDailyMileageReport(date, { readOnly: !!c.get('user')?.preview })); } catch (error: unknown) { + if (error instanceof DailyReportStorageUnavailable) + return c.json({ message: error.message, code: 'PREVIEW_REPORT_STORAGE_UNAVAILABLE' }, 503); console.error('mileage daily report error:', error); return c.json({ message: error instanceof Error ? error.message : '日报生成失败' }, 500); } diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index fc7140e..eaa5f1a 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -37,6 +37,13 @@ import { } from './vehicles/utils.js'; import { vehicleRepository, type FlowDetailRow, type WeeklyDetailType } from './vehicles/repository.js'; +import type { AssetAnalysisMetric, AssetAnalysisScope } from '../../shared/assets/analytics.js'; +import { + abnormalElapsedDays, analysisCellForVehicle, buildAssetAnalysis, loadMovementApprovalTimes, + matchesAnalysisFilters, matchesAnalysisFilterValues, matchesAnalysisMetric, + validAnalysisFilters, validAnalysisFilterValues, validAnalysisScope, +} from './vehicles/analytics.js'; + export { mapRegion } from './vehicles/model.js'; const app = new Hono(); @@ -354,6 +361,54 @@ app.get('/customer-stats', async (c) => { return c.json(result); }); +/** Analysis uses the same permission-filtered collection and exact entry scope as details. */ +app.on(['GET', 'POST'], '/analysis', async (c) => { + const body = c.req.method === 'POST' ? await c.req.json().catch(() => null) : {}; + if (!body || typeof body !== 'object' || Array.isArray(body)) return c.json({ error: '统计范围无效' }, 400); + const scope = body.scope || {}; + if (!validAnalysisScope(scope) || (body.inventoryKeys !== undefined && (!Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')))) { + return c.json({ error: '统计范围无效' }, 400); + } + let vehicles = await filterVehicleListScope(await getVehiclesForUser(c), scope); + if (body.inventoryKeys) vehicles = await filterVehicleListScope(vehicles, { inventoryKeys: body.inventoryKeys }); + const approvals = await loadMovementApprovalTimes(vehicles); + return c.json(buildAssetAnalysis(vehicles, approvals, Date.now())); +}); + +app.post('/analysis/details', async (c) => { + const body = await c.req.json().catch(() => null); + const filterValues = body?.filterValues === undefined ? {} : body.filterValues; + const metrics: AssetAnalysisMetric[] = ['assets', 'operating', 'inventory', 'abnormal', 'pending']; + if (!body || !metrics.includes(body.metric) || !validAnalysisFilters(body.filters || {}) || !validAnalysisScope(body.scope || {}) + || !validAnalysisFilterValues(filterValues) + || (body.inventoryKeys !== undefined && (!Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')))) { + return c.json({ error: '下钻条件无效' }, 400); + } + const now = body.generatedAt === undefined ? Date.now() : typeof body.generatedAt === 'string' ? Date.parse(body.generatedAt) : NaN; + if (!Number.isFinite(now) || now > Date.now() + 60000) return c.json({ error: '统计时间无效,请刷新分析' }, 400); + let vehicles = await filterVehicleListScope(await getVehiclesForUser(c), body.scope || {}); + if (body.inventoryKeys) vehicles = await filterVehicleListScope(vehicles, { inventoryKeys: body.inventoryKeys }); + const approvals = await loadMovementApprovalTimes(vehicles); + const filtered = vehicles.filter(vehicle => { + const cell = analysisCellForVehicle(vehicle, approvals, now); + return matchesAnalysisMetric(cell, body.metric) + && matchesAnalysisFilters(cell, body.filters || {}) + && matchesAnalysisFilterValues(cell, filterValues); + }); + return c.json(filtered.map(vehicle => { + const result = vehicleListItem(vehicle); + const approvedAt = approvals.byMoveId.get(vehicle.abnormalMove?.id || ''); + return { + ...result, + abnormalMove: result.abnormalMove ? { + ...result.abnormalMove, + approvalPassedAt: approvedAt || null, + elapsedDays: abnormalElapsedDays(approvedAt, now), + } : null, + }; + })); +}); + // POST uses a JSON scope to avoid URL length limits for inventory subtotal drilldown. app.on(['GET', 'POST'], '/list', async (c) => { let inventoryKeys: Set | undefined; @@ -365,12 +420,20 @@ app.on(['GET', 'POST'], '/list', async (c) => { inventoryKeys = new Set(body.inventoryKeys); } const vehicles = await getVehiclesForUser(c); - const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query(); + const filtered = await filterVehicleListScope(vehicles, { ...c.req.query(), inventoryKeys: inventoryKeys ? [...inventoryKeys] : undefined }); + return c.json(filtered.map(vehicleListItem)); +}); - let filtered = c.req.query('source') === 'region' +async function filterVehicleListScope(vehicles: Vehicle[], scope: AssetAnalysisScope): Promise { + const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = scope; + + let filtered = scope.source === 'region' ? vehicles.filter(v => isOperatingVehicle(v) || isInventoryPending(v)) : vehicles; + const subject = (scope.subject || '').trim(); + if (subject) filtered = filtered.filter(v => v.subjectOrg === subject); + const inventoryKeys = scope.inventoryKeys ? new Set(scope.inventoryKeys) : undefined; if (inventoryKeys) { filtered = filtered.filter(v => isInventoryVehicle(v) && inventoryKeys.has(inventoryKey(inventoryDimensions(v)))); } @@ -388,7 +451,7 @@ app.on(['GET', 'POST'], '/list', async (c) => { } } if (vehicleType) { - if (vehicleType === '区域其他' && c.req.query('source') === 'region') { + if (vehicleType === '区域其他' && scope.source === 'region') { filtered = filtered.filter(v => !['4.5T', '18T', '49T'].includes(v.type)); } else if (VEHICLE_TYPE_FILTERS[vehicleType]) { filtered = filtered.filter(VEHICLE_TYPE_FILTERS[vehicleType]); @@ -408,7 +471,7 @@ app.on(['GET', 'POST'], '/list', async (c) => { filtered = filtered.filter((v) => v.model === model); } if (location && location !== 'All') { - filtered = filterByLocation(filtered, location, c.req.query('source')); + filtered = filterByLocation(filtered, location, scope.source); } if (status && status !== 'All') { filtered = filtered.filter((v) => v.status === status); @@ -446,8 +509,11 @@ app.on(['GET', 'POST'], '/list', async (c) => { filtered = filtered.filter((v) => wantTrailer ? (v.type === '挂车' || v.model.includes('挂车')) : !(v.type === '挂车' || v.model.includes('挂车'))); } - return c.json( - filtered.map((v) => ({ + return filtered; +} + +function vehicleListItem(v: Vehicle) { + return { id: v.id, plateNumber: v.plateNumber, vin: v.vin, @@ -471,9 +537,9 @@ app.on(['GET', 'POST'], '/list', async (c) => { customerManager: v.customerManager, brandLabel: v.brandLabel, orgName: v.orgName, - })), - ); -}); + }; +} + // Inventory keeps its existing aggregate visibility; vehicle details retain user permissions. const INVENTORY_TYPE_NAMES: Record = { t4_5: '4.5T普货', t4_5c: '4.5T冷链', t18: '18T', t49: '49T', trailer: '挂车', other: '其他' }; diff --git a/src/server/routes/vehicles/analytics.test.ts b/src/server/routes/vehicles/analytics.test.ts new file mode 100644 index 0000000..33f70ad --- /dev/null +++ b/src/server/routes/vehicles/analytics.test.ts @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import type { AuthUser } from '../../auth/types.js'; +import type { Vehicle } from '../../types.js'; +import type { AssetAnalysisResponse, AssetAnalysisScope } from '../../../shared/assets/analytics.js'; +import app from '../vehicles.js'; +import { vehicleRepository } from './repository.js'; +import { + abnormalAgeBucket, abnormalElapsedDays, analysisCellForVehicle, buildAssetAnalysis, + matchesAnalysisFilters, matchesAnalysisFilterValues, matchesAnalysisMetric, + validAnalysisFilters, validAnalysisFilterValues, validAnalysisScope, + type ApprovalTimes, +} from './analytics.js'; + +const NOW = Date.parse('2026-09-28T12:00:00+08:00'); +const approvals: ApprovalTimes = { available: true, byMoveId: new Map() }; +const make = (id: string, overrides: Partial = {}): Vehicle => ({ + id, plateNumber: `TEST-${id}`, vin: `PRIVATE-VIN-${id}`, type: '4.5T', model: '现代4.5T普货', + color: '白色', location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市', + status: 'Operating', operationStatus: '1', ownership: 'Self', rentCompany: '', + contractNo: '合同甲', customerName: '测试客户公司', orgName: null, departmentName: '部门甲', + subjectOrg: '公司甲', projectName: null, customerManager: '负责人甲', managerId: 'manager-a', + brandLabel: '现代', ...overrides, +}); + +test('duration buckets use exact approval-time boundaries and reject missing or future dates', () => { + const since = (days: number) => new Date(NOW - days * 86400000).toISOString(); + for (const [days, expected] of [[0, '0-3d'], [2.999, '0-3d'], [3, '3-7d'], [6.999, '3-7d'], [7, '7-15d'], [14.999, '7-15d'], [15, '15-30d'], [29.999, '15-30d'], [30, '30d+'], [90, '30d+']] as const) { + assert.equal(abnormalAgeBucket(since(days), NOW), expected); + } + for (const invalid of [undefined, '', 'not-a-date', since(-1)]) { + assert.equal(abnormalAgeBucket(invalid, NOW), 'unknown'); + assert.equal(abnormalElapsedDays(invalid, NOW), null); + } + assert.equal(abnormalElapsedDays('2026-09-27T12:00:00+08:00', NOW), 1); + assert.equal(abnormalAgeBucket('2026-09-25T04:00:00Z', NOW), '3-7d'); +}); + +test('asset dispositions reconcile to total while abnormal and pending stay inventory subsets', () => { + const data = [ + make('leased'), make('self', { operationStatus: '2' }), + make('available', { operationStatus: '3', status: 'Inventory' }), + make('abnormal', { operationStatus: '3', status: 'Abnormal' }), + make('pending', { operationStatus: '4', status: 'Pending' }), + make('unknown', { operationStatus: null, status: 'Inventory' }), + ]; + const result = buildAssetAnalysis(data, approvals, NOW); + assert.equal(result.summary.totalAssets, 6); + assert.equal(result.summary.operating.total, 2); + assert.equal(result.summary.operating.leased, 1); + assert.equal(result.summary.operating.self, 1); + assert.deepEqual(result.summary.inventory, { total: 3, inStock: 1, abnormal: 1 }); + assert.equal(result.summary.pendingDelivery, 1); + assert.equal(result.summary.otherAssets, 1); + assert.equal(result.cells.reduce((sum, row) => sum + row.quantity, 0), 6); + const count = (metric: 'assets' | 'operating' | 'inventory' | 'abnormal' | 'pending') => result.cells.filter(cell => matchesAnalysisMetric(cell, metric)).reduce((sum, row) => sum + row.quantity, 0); + assert.deepEqual(['assets', 'operating', 'inventory', 'abnormal', 'pending'].map(metric => count(metric as Parameters[0])), [6, 2, 3, 1, 1]); + assert.equal(analysisCellForVehicle(data[0], approvals, NOW).assetDisposition, '租赁'); + assert.equal(analysisCellForVehicle(data[1], approvals, NOW).assetDisposition, '自营'); + assert.equal(analysisCellForVehicle(data[2], approvals, NOW).assetDisposition, '在库'); + assert.equal(analysisCellForVehicle(data[5], approvals, NOW).assetDisposition, '其他'); +}); + +test('one grouping keeps municipality, cold-chain and other-type detail filters consistent', () => { + const first = make('1', { province: '上海市', city: '松江区', model: '现代4.5T冷链' }); + const second = make('2', { province: '上海市', city: '浦东新区', model: '现代4.5T冷链' }); + const result = buildAssetAnalysis([first, second, make('3', { type: '挂车', model: '挂车' })], approvals, NOW); + const cold = result.cells.find(cell => cell.type === '4.5T冷链')!; + assert.equal(cold.quantity, 2); + assert.equal(cold.city, '上海市'); + assert.equal(result.cells.find(cell => cell.type === '其他')?.quantity, 1); + assert.equal(matchesAnalysisFilters(cold, { region: '华东', city: '上海市', type: '4.5T冷链' }), true); + assert.equal(matchesAnalysisFilters(cold, { region: '华南', city: '上海市' }), false); +}); + +test('analysis regions and cities exactly match inventory statistics and retain unmapped locations', async (t) => { + const stock = (id: string, province: string | null, city: string | null) => make(id, { + operationStatus: '3', status: 'Inventory', province, city, + // This legacy field intentionally disagrees with coordinates: neither API may use it. + location: '广东', region: '广东', + }); + const vehicles = [ + stock('east-1', '上海市', '松江区'), stock('east-2', '上海市', '浦东新区'), + stock('south', '广东省', '广州市'), stock('northwest', '新疆', '乌鲁木齐市'), + stock('southwest', '四川省', '成都市'), stock('north', '北京市', '朝阳区'), + stock('central', '湖北省', '武汉市'), stock('unmapped', null, null), + ]; + t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles); + const inventoryResponse = await app.request('/inventory-stats?subject=公司甲'); + const analysisResponse = await app.request('/analysis?subject=公司甲'); + assert.equal(inventoryResponse.status, 200); + assert.equal(analysisResponse.status, 200); + const inventory = await inventoryResponse.json() as Array<{ + region: string; city: string; brand: string; type: string; model: string; quantity: number; + }>; + const analysis = await analysisResponse.json() as AssetAnalysisResponse; + const locations = (rows: Array<{ region: string; city: string; quantity: number }>) => rows + .map(row => ({ region: row.region, city: row.city, quantity: row.quantity })) + .sort((a, b) => `${a.region}:${a.city}`.localeCompare(`${b.region}:${b.city}`)); + assert.deepEqual(locations(analysis.cells), locations(inventory)); + assert.equal(analysis.summary.inventory.total, vehicles.length); + for (const name of ['华东', '华南', '西北', '西南']) assert.ok(analysis.cells.some(cell => cell.region === name)); + // The inventory mapper also supports these regions. Do not arbitrarily reassign or discard them. + assert.ok(analysis.cells.some(cell => cell.region === '华北')); + assert.ok(analysis.cells.some(cell => cell.region === '华中')); + const unknown = analysis.cells.find(cell => cell.region === '其他'); + assert.equal(unknown?.quantity, 1); + assert.equal(unknown?.city, '其他'); + for (const row of inventory) { + const key = JSON.stringify([row.region, row.city, row.brand, row.type, row.model]); + const scopedResponse = await app.request('/analysis?subject=公司甲', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inventoryKeys: [key] }), + }); + assert.equal(scopedResponse.status, 200); + const scoped = await scopedResponse.json() as AssetAnalysisResponse; + assert.equal(scoped.summary.inventory.total, row.quantity); + assert.ok(scoped.cells.every(cell => cell.region === row.region && cell.city === row.city)); + const details = await app.request('/analysis/details?subject=公司甲', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metric: 'inventory', inventoryKeys: [key], filters: { region: row.region, city: row.city } }), + }); + assert.equal(details.status, 200); + assert.equal((await details.json() as unknown[]).length, row.quantity); + } +}); + +test('abnormal classification never substitutes target or actual completion for approval time', () => { + const vehicle = make('1', { + operationStatus: '3', status: 'Abnormal', abnormalMove: { + id: 'move-1', type: '维修', work: 'PRIVATE-WORK', targetCompletionTime: '2026-09-01 12:00', + actualCompletionTime: '2026-09-27 12:00', initiatingDepartment: null, executingDepartment: null, + responsiblePerson: null, driver: null, status: '待结束', + }, + }); + assert.equal(analysisCellForVehicle(vehicle, approvals, NOW).abnormalAge, 'unknown'); + const history = { available: true, byMoveId: new Map([['move-1', '2026-09-21T12:00:00+08:00']]) }; + assert.equal(analysisCellForVehicle(vehicle, history, NOW).abnormalAge, '7-15d'); + const noWorkflow = buildAssetAnalysis([vehicle], { available: false, byMoveId: new Map() }, NOW); + assert.equal(noWorkflow.approvalTimeAvailable, false); + assert.equal(noWorkflow.summary.inventory.abnormal, 1); +}); + +test('scope and dimensional filter validation rejects unknown keys, arrays and malformed enums', () => { + assert.equal(validAnalysisScope({ inventoryKeys: [], subject: null, attendance: 'idle', isColdChain: 'false' }), true); + assert.equal(validAnalysisFilters({ region: '华东', assetDisposition: '在库', abnormalAge: 'unknown' }), true); + for (const bad of [null, [], 'all', { managerId: 'other' }, { inventoryKeys: [1] }, { inventoryKeys: null }, { category: 'anything' }, { attendance: 'all' }, { isTrailer: false }, { isColdChain: 'yes' }, { subject: 4 }]) { + assert.equal(validAnalysisScope(bad), false, JSON.stringify(bad)); + } + for (const bad of [null, [], { quantity: 10 }, { region: ['华东'] }, { unknown: 'value' }]) { + assert.equal(validAnalysisFilters(bad), false, JSON.stringify(bad)); + } +}); + +test('multi-value filters validate known dimensions and string arrays without changing single-value filters', () => { + for (const valid of [{}, { region: [] }, { region: ['华东', '华南'], type: ['4.5T普货', '18T'] }]) { + assert.equal(validAnalysisFilterValues(valid), true); + } + for (const invalid of [undefined, null, [], 'all', { region: '华东' }, { region: [1] }, { region: ['华东', null] }, { region: [['华东']] }, { region: null }, { region: undefined }, { notADimension: [] }]) { + assert.equal(validAnalysisFilterValues(invalid), false, JSON.stringify(invalid)); + } + assert.equal(validAnalysisFilters({ region: ['华东', '华南'] }), false); +}); + +test('multi-value alternatives are ORed inside a dimension and ANDed with row filters', () => { + const cell = analysisCellForVehicle(make('1'), approvals, NOW); + assert.equal(matchesAnalysisFilterValues(cell, {}), true); + assert.equal(matchesAnalysisFilterValues(cell, { region: [], type: [] }), true); + assert.equal(matchesAnalysisFilterValues(cell, { region: ['华东', '华南'], type: ['18T', '4.5T普货'] }), true); + assert.equal(matchesAnalysisFilterValues(cell, { region: ['华东', '华南'], type: ['18T'] }), false); + assert.equal(matchesAnalysisFilterValues(cell, { region: ['华南'], type: ['4.5T普货'] }), false); + const multiMatches = matchesAnalysisFilterValues(cell, { region: ['华东', '华南'] }); + assert.equal(multiMatches && matchesAnalysisFilters(cell, { region: '华东' }), true); + assert.equal(multiMatches && matchesAnalysisFilters(cell, { region: '华南' }), false); +}); + +test('detail multi-select preserves permissions, company, scope and a clicked row intersection', async (t) => { + const vehicles = [ + make('east-light'), make('east-heavy', { type: '18T', model: '飞驰18T' }), + make('south-light', { province: '广东省', city: '广州市' }), + make('south-heavy', { province: '广东省', city: '广州市', type: '18T', model: '飞驰18T' }), + make('hidden-user', { managerId: 'manager-b' }), + make('hidden-company', { subjectOrg: '公司乙' }), + make('hidden-department', { departmentName: '部门乙' }), + make('inventory', { operationStatus: '3', status: 'Inventory' }), + ]; + t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles); + const secured = new Hono<{ Variables: { user: AuthUser } }>(); + secured.use('*', async (c, next) => { + c.set('user', { userId: 'manager-a', userName: '测试', loginName: 'test', depCode: 'a', depName: '部门甲', permissionLevel: 'personal', roles: [] }); + await next(); + }); + secured.route('/', app); + const request = (extra: Record) => secured.request('/analysis/details?subject=公司甲', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metric: 'operating', scope: { department: '部门甲' }, filters: {}, ...extra }), + }); + const ids = async (extra: Record) => { + const response = await request(extra); + assert.equal(response.status, 200); + return (await response.json() as Array<{ id: string }>).map(row => row.id); + }; + assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'], type: ['4.5T普货', '18T'] } }), ['east-light', 'east-heavy', 'south-light', 'south-heavy']); + assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'], type: ['4.5T普货'] }, filters: { region: '华东' } }), ['east-light']); + assert.deepEqual(await ids({ filterValues: { region: ['华南'] }, filters: { region: '华东' } }), []); + assert.deepEqual(await ids({ filterValues: { region: [], type: [] }, filters: { type: '18T' } }), ['east-heavy', 'south-heavy']); + assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'] }, scope: { subject: '公司乙' } }), []); + for (const invalid of [null, [], { region: '华东' }, { region: [1] }, { managerId: ['manager-b'] }]) { + assert.equal((await request({ filterValues: invalid })).status, 400); + } + assert.equal((await request({ filters: { region: ['华东'] } })).status, 400); +}); + +test('analysis and final details intersect company, permissions and exact entry scope without leaking row fields', async (t) => { + const vehicles = [make('visible'), make('hidden', { managerId: 'manager-b' }), make('other-company', { subjectOrg: '公司乙' })]; + t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles); + const secured = new Hono<{ Variables: { user: AuthUser } }>(); + secured.use('*', async (c, next) => { + c.set('user', { userId: 'manager-a', userName: '测试', loginName: 'test', depCode: 'a', depName: '部门甲', permissionLevel: 'personal', roles: [] }); + await next(); + }); + secured.route('/', app); + const post = (path: string, body: unknown) => secured.request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const response = await post('/analysis?subject=公司甲', { scope: { model: '现代4.5T普货' } }); + assert.equal(response.status, 200); + const aggregate = await response.json() as AssetAnalysisResponse; + assert.equal(aggregate.summary.totalAssets, 1); + const serialized = JSON.stringify(aggregate); + for (const secret of ['PRIVATE-VIN', 'TEST-visible', 'manager-a', 'manager-b', '测试客户公司', 'plateNumber', 'managerId', 'contractNo']) { + assert.equal(serialized.includes(secret), false, `aggregate leaked ${secret}`); + } + const details = await post('/analysis/details?subject=公司甲', { metric: 'operating', filters: { region: '华东' }, generatedAt: aggregate.generatedAt, scope: { model: '现代4.5T普货' } }); + assert.equal(details.status, 200); + assert.deepEqual((await details.json() as Array<{ id: string }>).map(row => row.id), ['visible']); + const excluded = await post('/analysis?subject=公司甲', { scope: { subject: '公司乙' } }); + assert.equal((await excluded.json() as AssetAnalysisResponse).summary.totalAssets, 0); +}); + +test('analysis inherits legacy list filtering including attendance and empty inventory scopes', async (t) => { + const rows = [ + make('1'), make('2', { model: '现代4.5T冷链' }), + make('3', { operationStatus: '3', status: 'Inventory' }), + make('4', { type: '挂车', model: '挂车' }), + ]; + t.mock.method(vehicleRepository, 'getVehicles', async () => rows); + t.mock.method(vehicleRepository, 'getAttendanceMileageRows', async () => [ + { plate_number: 'TEST-1', day_mileage: 10 }, { plate_number: 'TEST-2', day_mileage: 0 }, + ]); + const scopes: AssetAnalysisScope[] = [ + { source: 'region', location: '华东' }, { vehicleType: '4.5T冷链', category: 'Operating' }, + { department: '部门甲', manager: '负责人甲', attendance: 'active' }, + { isTrailer: 'true' }, { isColdChain: 'false', brand: '现代', batch: '合同甲' }, + { subject: ' 公司甲 ' }, { inventoryKeys: [] }, + ]; + for (const scope of scopes) { + const { inventoryKeys, ...params } = scope; + const query = new URLSearchParams(Object.entries(params).filter((pair): pair is [string, string] => typeof pair[1] === 'string')); + const listResponse = await app.request(`/list?${query}`, inventoryKeys ? { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inventoryKeys }), + } : undefined); + const analysisResponse = await app.request('/analysis', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }), + }); + assert.equal(analysisResponse.status, 200); + assert.equal((await analysisResponse.json() as AssetAnalysisResponse).summary.totalAssets, (await listResponse.json() as unknown[]).length, JSON.stringify(scope)); + } + for (const body of [{ scope: { inventoryKeys: [42] } }, { scope: { unauthorizedField: 'x' } }, { inventoryKeys: 'all' }]) { + assert.equal((await app.request('/analysis', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).status, 400); + } +}); diff --git a/src/server/routes/vehicles/analytics.ts b/src/server/routes/vehicles/analytics.ts new file mode 100644 index 0000000..d5fccc2 --- /dev/null +++ b/src/server/routes/vehicles/analytics.ts @@ -0,0 +1,181 @@ +import pool from '../../db.js'; +import type { Vehicle } from '../../types.js'; +import { + ASSET_ANALYSIS_DIMENSIONS, + type AbnormalAgeBucket, + type AssetAnalysisCell, + type AssetAnalysisFilters, + type AssetAnalysisFilterValues, + type AssetAnalysisMetric, + type AssetAnalysisResponse, + type AssetAnalysisScope, +} from '../../../shared/assets/analytics.js'; +import { + isInventoryAbnormal, isInventoryPending, isInventoryVehicle, isOperatingVehicle, + mapMacroRegion, resolveCity, +} from './model.js'; + +export interface ApprovalTimes { + available: boolean; + byMoveId: Map; +} + +/** + * Verified workflow source: movement = 异动审批; business_id is the movement id. + * Only a PASS task reaching an end node in a finished instance counts as approval. + * flow_his_task.update_time is documented as 审批完成时间. Movement start/create + * timestamps are intentionally never substituted when workflow history is absent. + */ +export async function loadMovementApprovalTimes(vehicles: Vehicle[]): Promise { + const ids = [...new Set(vehicles.filter(isInventoryAbnormal).flatMap(v => v.abnormalMove?.id ? [v.abnormalMove.id] : []))]; + if (!ids.length) return { available: true, byMoveId: new Map() }; + try { + const [rows] = await pool.query(` + SELECT f.business_id AS move_id, + DATE_FORMAT(MAX(h.update_time), '%Y-%m-%dT%H:%i:%s+08:00') AS approved_at + FROM \`ry-workflow\`.flow_instance f + JOIN \`ry-workflow\`.flow_definition d ON d.id = f.definition_id AND d.flow_code = 'movement' + JOIN \`ry-workflow\`.flow_his_task h ON h.instance_id = f.id + AND h.del_flag = '0' AND h.skip_type = 'PASS' AND h.flow_status IN ('pass', '2') + JOIN \`ry-workflow\`.flow_node n ON n.definition_id = f.definition_id + AND n.node_code = h.target_node_code AND n.node_type = 2 AND n.del_flag = '0' + WHERE f.del_flag = '0' AND f.flow_status IN ('finish', '8') + AND f.business_id IN (${ids.map(() => '?').join(',')}) + GROUP BY f.business_id`, ids); + return { + available: true, + byMoveId: new Map((rows as Array<{ move_id: string; approved_at: string | null }> ) + .filter(row => row.approved_at).map(row => [String(row.move_id), row.approved_at!])), + }; + } catch { + // Workflow access is optional; totals remain usable and missing ages stay explicit. + return { available: false, byMoveId: new Map() }; + } +} + +export function abnormalElapsedDays(approvedAt: string | undefined, now: number): number | null { + const timestamp = approvedAt ? Date.parse(approvedAt) : NaN; + return Number.isFinite(timestamp) && timestamp <= now ? (now - timestamp) / 86400000 : null; +} + +export function abnormalAgeBucket(approvedAt: string | undefined, now: number): AbnormalAgeBucket { + const days = abnormalElapsedDays(approvedAt, now); + if (days === null) return 'unknown'; + if (days < 3) return '0-3d'; + if (days < 7) return '3-7d'; + if (days < 15) return '7-15d'; + if (days < 30) return '15-30d'; + return '30d+'; +} + +export function analysisCellForVehicle(vehicle: Vehicle, approvals: ApprovalTimes, now: number): AssetAnalysisCell { + const operating = isOperatingVehicle(vehicle); + const inventory = isInventoryVehicle(vehicle); + const abnormal = isInventoryAbnormal(vehicle); + const pending = isInventoryPending(vehicle); + const operation = (vehicle.operationStatus || '').trim(); + const operationKind = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record)[operation] || operation || '未分类'; + const inventoryState = !inventory ? 'not-applicable' : abnormal ? 'abnormal' : pending ? 'pending' + : operationKind === '可运营' ? 'available' : operationKind === '待运营' ? 'awaiting' : 'unclassified'; + return { + type: vehicle.type === '4.5T' ? (vehicle.model.includes('冷链') ? '4.5T冷链' : '4.5T普货') + : ['18T', '49T'].includes(vehicle.type) ? vehicle.type : '其他', + model: vehicle.model || '未标注型号', + // Match /inventory-stats inventoryDimensions exactly: the legacy vehicle.location + // field uses a different, older region taxonomy and must not drive BI drill-down. + // Keep the mapper's unknown bucket so missing locations never disappear. + region: mapMacroRegion(vehicle.province, vehicle.city), + city: resolveCity(vehicle.city, vehicle.province), + assetStatus: operating ? 'operating' : inventory ? 'inventory' : 'other', + assetDisposition: inventory ? '在库' : operating ? (operationKind === '租赁' ? '租赁' : operationKind === '自营' ? '自营' : '其他运营') : '其他', + operationKind, + inventoryState, + abnormalType: abnormal ? (vehicle.abnormalMove?.type || '未提供') : '不适用', + abnormalAge: abnormal ? abnormalAgeBucket(approvals.byMoveId.get(vehicle.abnormalMove?.id || ''), now) : 'unknown', + department: vehicle.model === '公务车/挂靠车' ? '公务车' : vehicle.departmentName || '未分配部门', + manager: vehicle.customerManager || '未分配', + customer: vehicle.customerName || '未分配客户', + quantity: 1, + }; +} + +export function matchesAnalysisMetric(cell: AssetAnalysisCell, metric: AssetAnalysisMetric): boolean { + if (metric === 'assets') return true; + if (metric === 'operating') return cell.assetStatus === 'operating'; + if (metric === 'inventory') return cell.assetStatus === 'inventory'; + if (metric === 'abnormal') return cell.inventoryState === 'abnormal'; + return cell.inventoryState === 'pending'; +} + +export function matchesAnalysisFilters(cell: AssetAnalysisCell, filters: AssetAnalysisFilters): boolean { + return ASSET_ANALYSIS_DIMENSIONS.every(dimension => filters[dimension] === undefined || cell[dimension] === filters[dimension]); +} + +export function matchesAnalysisFilterValues(cell: AssetAnalysisCell, filterValues: AssetAnalysisFilterValues): boolean { + return ASSET_ANALYSIS_DIMENSIONS.every(dimension => { + const values = filterValues[dimension]; + return !values?.length || values.includes(cell[dimension]); + }); +} + +export function buildAssetAnalysis(vehicles: Vehicle[], approvals: ApprovalTimes, now: number): AssetAnalysisResponse { + const groups = new Map(); + for (const vehicle of vehicles) { + const cell = analysisCellForVehicle(vehicle, approvals, now); + const key = JSON.stringify(ASSET_ANALYSIS_DIMENSIONS.map(dimension => cell[dimension])); + const existing = groups.get(key); + if (existing) existing.quantity += 1; + else groups.set(key, cell); + } + const operating = vehicles.filter(isOperatingVehicle); + const inventory = vehicles.filter(isInventoryVehicle); + return { + generatedAt: new Date(now).toISOString(), + approvalTimeAvailable: approvals.available, + summary: { + totalAssets: vehicles.length, + operating: { + total: operating.length, + self: operating.filter(v => ['2', '自营'].includes((v.operationStatus || '').trim())).length, + leased: operating.filter(v => ['1', '租赁'].includes((v.operationStatus || '').trim())).length, + public: operating.filter(v => v.ownership === 'Public').length, + hanging: 0, + }, + inventory: { + total: inventory.length, + inStock: inventory.filter(v => v.status === 'Inventory').length, + abnormal: inventory.filter(isInventoryAbnormal).length, + }, + pendingDelivery: inventory.filter(isInventoryPending).length, + otherAssets: vehicles.length - operating.length - inventory.length, + }, + cells: [...groups.values()].sort((a, b) => b.quantity - a.quantity), + }; +} + +export function validAnalysisFilters(value: unknown): value is AssetAnalysisFilters { + return value !== null && typeof value === 'object' && !Array.isArray(value) + && Object.entries(value).every(([key, item]) => ASSET_ANALYSIS_DIMENSIONS.includes(key as typeof ASSET_ANALYSIS_DIMENSIONS[number]) && typeof item === 'string'); +} + +export function validAnalysisFilterValues(value: unknown): value is AssetAnalysisFilterValues { + return value !== null && typeof value === 'object' && !Array.isArray(value) + && Object.entries(value).every(([key, values]) => + ASSET_ANALYSIS_DIMENSIONS.includes(key as typeof ASSET_ANALYSIS_DIMENSIONS[number]) + && Array.isArray(values) && values.every(item => typeof item === 'string')); +} + +export function validAnalysisScope(value: unknown): value is AssetAnalysisScope { + const keys = new Set(['brand', 'batch', 'model', 'location', 'status', 'category', 'vehicleType', 'manager', 'customer', 'department', 'attendance', 'isColdChain', 'isTrailer', 'source', 'subject']); + return value !== null && typeof value === 'object' && !Array.isArray(value) + && Object.entries(value).every(([key, item]) => { + if (key === 'inventoryKeys') return Array.isArray(item) && item.every(entry => typeof entry === 'string'); + if (!keys.has(key)) return false; + if (key === 'subject' && item === null) return true; + if (typeof item !== 'string') return false; + if (key === 'category') return ['Inventory', 'Abnormal', 'Operating', 'Pending'].includes(item); + if (key === 'attendance') return ['active', 'idle'].includes(item); + if (key === 'isColdChain' || key === 'isTrailer') return ['true', 'false'].includes(item); + return true; + }); +} diff --git a/src/server/routes/vehicles/repository.test.ts b/src/server/routes/vehicles/repository.test.ts index ddd41f4..d873530 100644 --- a/src/server/routes/vehicles/repository.test.ts +++ b/src/server/routes/vehicles/repository.test.ts @@ -39,6 +39,7 @@ test('keeps all vehicle GET endpoints in their established order', () => { '/dept-stats', '/region-stats', '/customer-stats', + '/analysis', '/list', '/inventory-stats', '/weekly-detail', diff --git a/src/shared/assets/abnormal-move.ts b/src/shared/assets/abnormal-move.ts index f2e6552..d2224c1 100644 --- a/src/shared/assets/abnormal-move.ts +++ b/src/shared/assets/abnormal-move.ts @@ -1,5 +1,9 @@ /** Current or most recent effective movement associated with a vehicle. */ export interface AbnormalMoveDetail { + /** Final PASS task completion time of the movement approval workflow. */ + approvalPassedAt?: string | null; + /** Elapsed days at the analysis snapshot time; null when approval time is unknown. */ + elapsedDays?: number | null; id: string; type: string; work: string | null; diff --git a/src/shared/assets/analytics.ts b/src/shared/assets/analytics.ts new file mode 100644 index 0000000..2c5d7a0 --- /dev/null +++ b/src/shared/assets/analytics.ts @@ -0,0 +1,71 @@ +/** Aggregated management analysis; dimensions are shared by aggregates and final details. */ +export const ASSET_ANALYSIS_DIMENSIONS = [ + 'type', 'model', 'region', 'city', 'assetStatus', 'assetDisposition', 'operationKind', + 'inventoryState', 'abnormalType', 'abnormalAge', 'department', 'manager', 'customer', +] as const; + +export type AssetAnalysisDimension = typeof ASSET_ANALYSIS_DIMENSIONS[number]; +export type AssetAnalysisMetric = 'assets' | 'operating' | 'inventory' | 'abnormal' | 'pending'; +export type AssetAnalysisFilters = Partial>; +/** Values within a dimension are ORed; dimensions and the entry scope are ANDed. */ +export type AssetAnalysisFilterValues = Partial>; +export type AbnormalAgeBucket = '0-3d' | '3-7d' | '7-15d' | '15-30d' | '30d+' | 'unknown'; + +export interface AssetAnalysisCell extends Record { + assetStatus: 'operating' | 'inventory' | 'other'; + assetDisposition: '在库' | '租赁' | '自营' | '其他运营' | '其他'; + inventoryState: 'abnormal' | 'pending' | 'available' | 'awaiting' | 'unclassified' | 'not-applicable'; + abnormalAge: AbnormalAgeBucket; + quantity: number; +} + +export interface AssetAnalysisSummary { + totalAssets: number; + operating: { total: number; self: number; leased: number; public: number; hanging: number }; + inventory: { total: number; inStock: number; abnormal: number }; + pendingDelivery: number; + otherAssets: number; +} + +export interface AssetAnalysisResponse { + generatedAt: string; + summary: AssetAnalysisSummary; + cells: AssetAnalysisCell[]; + /** True only when a verified approval time source is available. */ + approvalTimeAvailable: boolean; +} + +export interface AssetAnalysisDetailRequest { + metric: AssetAnalysisMetric; + filters: AssetAnalysisFilters; + filterValues?: AssetAnalysisFilterValues; + /** Use the aggregate's reference time to keep duration bucket boundaries consistent. */ + generatedAt?: string; + scope?: AssetAnalysisScope; + inventoryKeys?: string[]; +} + +/** Carries the existing dashboard entry's exact list scope into every drill level. */ +export interface AssetAnalysisScope { + inventoryKeys?: string[]; + brand?: string; + batch?: string; + model?: string; + location?: string; + status?: string; + category?: string; + vehicleType?: string; + manager?: string; + customer?: string; + department?: string; + attendance?: string; + isColdChain?: string; + isTrailer?: string; + source?: string; + subject?: string | null; +} + +export interface AssetAnalysisRequest { + scope?: AssetAnalysisScope; + inventoryKeys?: string[]; +} diff --git a/src/shared/auth/roles.ts b/src/shared/auth/roles.ts index 2452bb5..9fe53a7 100644 --- a/src/shared/auth/roles.ts +++ b/src/shared/auth/roles.ts @@ -34,3 +34,10 @@ export function canAccessEnergy(roles: readonly string[] | null | undefined): bo if (!roles || roles.length === 0) return false; return roles.some(r => ENERGY_ACCESS_ROLES.includes(r) || r === ENERGY_FULL_ACCESS); } + +/** Shared by normal login and permission preview; full access wins over department. */ +export function resolvePermissionLevel(roles: readonly string[]): 'full' | 'department' | 'personal' { + if (roles.some(role => FULL_ACCESS_ROLES.includes(role))) return 'full'; + if (roles.some(role => DEPT_ACCESS_ROLES.includes(role))) return 'department'; + return 'personal'; +}