feat(assets): add dimension analysis and authorized user previews
ci/woodpecker/push/woodpecker Pipeline was successful

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.
This commit is contained in:
shishengliang
2026-09-28 12:29:51 +08:00
parent 6de77adb47
commit f1b3f8d3da
69 changed files with 3871 additions and 536 deletions
+3
View File
@@ -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
+29
View File
@@ -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会话;本地免登录不能替代此项。
+11 -3
View File
@@ -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 <UnauthorizedPage message={error || undefined} />;
}
if (routeKey === "who") return <PermissionPreviewPage />;
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
if (routeKey === "ele/import") {
return (
@@ -93,10 +96,15 @@ function AuthGate() {
return <Shell key={pathSet} modules={modules} />;
}
function AuthenticatedWorkspace() {
const { identityVersion = 0 } = useAuth();
return <PermissionPreview><AuthGate key={identityVersion} /></PermissionPreview>;
}
export default function App() {
return (
<AuthProvider>
<AuthGate />
<AuthenticatedWorkspace />
</AuthProvider>
);
}
+2
View File
@@ -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'), '');
});
+2 -1
View File
@@ -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<string, { path: string; hash?: string }> = {
'/': { 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';
}
+130 -31
View File
@@ -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<string | null>(null);
const authStarted = useRef(false);
const previewTokenRef = useRef<string | null>(null);
const actorRef = useRef<AuthUser | null>(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<string | null>(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: {
const devUser: AuthUser = {
userId: 'dev-local',
userName: '本地开发',
permissionLevel: 'full',
depName: '',
roles: ['所有权限', 'BI-SCHEDULE-OPT', 'BI-ADMIN-FEEDBACK', 'BI-LEADER-ENERGY'],
},
error: null,
});
};
// 预览授权仍由后端独立开发开关决定,不使用真实用户令牌。
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 (
<AuthContext.Provider value={state}>
<AuthContext.Provider value={{ ...state, actor: actorRef.current || state.user,
user: preview?.user || state.user, preview, canPreview, identityVersion, previewNotice,
startPreview, stopPreview, clearPreviewNotice: () => setPreviewNotice(null) }}>
{children}
</AuthContext.Provider>
);
+31
View File
@@ -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)}}
+85
View File
@@ -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 <div className={preview ? 'permission-preview-active' : undefined}>
{isAuthenticated && preview && <aside className="permission-preview-banner" aria-label="当前权限视角">
<Eye size={17} /><div className="permission-preview-identity"><strong>只读预览:{preview.user.userName}</strong><span>{preview.user.depName || '未分配部门'} · {permissionScope(preview.user)}<small>登录人:{actor?.userName}</small></span></div>
<button onClick={() => window.location.assign('/who')}>切换人员</button><button onClick={() => void stopPreview?.()}>退出预览</button>
</aside>}
{previewNotice && <div className="permission-preview-notice" role="status">{previewNotice}<button onClick={clearPreviewNotice} aria-label="关闭权限提示"><X size={14} /></button></div>}
{children}
</div>;
}
export function PermissionPreviewPage() {
const { canPreview } = useAuth();
return <main className="permission-preview-page">
{canPreview ? <PreviewPicker onClose={() => window.location.assign('/asset#assets')} /> : <section className="permission-preview-denied"><ShieldCheck size={28} /><h1>无权限访问</h1><p>当前账号未获授权使用权限视角。</p><a href="/asset#assets">返回 BI</a></section>}
</main>;
}
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<AuthUser[]>([]);
const [hasMore, setHasMore] = useState(false);
const [selected, setSelected] = useState<AuthUser | null>(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 <section className="permission-preview-dialog permission-preview-page-panel" aria-labelledby="permission-preview-title">
<header><div><h2 id="permission-preview-title"><ShieldCheck size={19} />权限视角</h2><p>选择人员,查看其实际页面权限与数据范围。</p></div><button disabled={busy} className="permission-preview-back" onClick={onClose}><ArrowLeft size={18} aria-hidden="true" />返回 BI</button></header>
<div className="permission-preview-body">
<div className="permission-preview-guide" aria-label="三类数据权限说明">{scopes.map(scope => <div key={scope.level}><strong>{scope.title}</strong><code>{scope.role}</code><p>{scope.detail}</p></div>)}</div>
<p className="permission-preview-hint">菜单和数据随目标用户权限变化;共享汇总仍按页面注明的公共口径展示。预览期间仅可查看,不能修改业务数据。</p>
<div className="permission-preview-search"><label><Search size={16} /><input autoFocus type="search" value={query} disabled={busy} onChange={e => { setQuery(e.target.value); setPage(1); }} placeholder="搜索中文姓名、用户名或部门" aria-label="搜索人员" /></label><select aria-label="数据范围筛选" value={level} disabled={busy} onChange={e => { setLevel(e.target.value); setPage(1); }}><option value="">全部数据范围</option>{scopes.map(s => <option key={s.level} value={s.level}>{s.title}</option>)}</select></div>
{error && <div className="permission-preview-error" role="alert">{error}<button disabled={busy} onClick={() => setRetry(v => v + 1)}>重试</button></div>}
{!hasQuery ? <p className="permission-preview-empty">输入中文姓名、用户名或部门关键词,模糊搜索人员。</p> : <div className="permission-preview-users" role="radiogroup" aria-label="选择预览用户" aria-busy={loading}>
{loading ? <p className="permission-preview-empty">正在加载人员…</p> : !users.length ? <p className="permission-preview-empty">未找到匹配人员,请调整搜索条件。</p> : users.map(user => <label key={user.userId} className={selected?.userId === user.userId ? 'is-selected' : ''}>
<input type="radio" name="preview-user" value={user.userId} checked={selected?.userId === user.userId} disabled={busy} onChange={() => setSelected(user)} />
<div><strong>{user.userName} {preview?.user.userId === user.userId && <small>当前视角</small>}</strong><span>{user.loginName || user.userId}</span></div><em>{permissionScope(user)}</em>
</label>)}
</div>}
{hasQuery && (hasMore || page > 1) && <div className="permission-preview-pages"><button disabled={page === 1 || loading || busy} onClick={() => setPage(v => v - 1)}>上一页</button><span>第 {page} 页</span><button disabled={!hasMore || loading || busy} onClick={() => setPage(v => v + 1)}>下一页</button></div>}
</div>
<footer><div>{selected ? <><strong>即将进入:{selected.userName}</strong><span>{permissionScope(selected)}</span></> : <span>请选择一位用户开始预览</span>}</div><button className="permission-preview-primary" disabled={!selected || busy || loading} onClick={() => void enter()}>{busy ? '正在切换…' : '进入该用户视角'}</button></footer>
</section>;
}
+46 -14
View File
@@ -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<Response> {
async function authenticatedRequest(url: string, options: RequestInit | undefined, preview: boolean, business: boolean): Promise<Response> {
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 (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<Response> {
return authenticatedRequest(url, options, true, true);
}
/** 管理接口始终使用实际登录身份;仅 current/stop 附带当前预览凭证。 */
export async function fetchPreviewControl<T>(path: string, options?: RequestInit, includePreview = false): Promise<T> {
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<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetchAuthenticated(url, options);
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
+20 -18
View File
@@ -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<void>;
stopPreview?: () => Promise<void>;
clearPreviewNotice?: () => void;
}
export const AuthContext = createContext<AuthState>({
isLoading: true,
isAuthenticated: false,
user: null,
error: null,
});
export function useAuth() {
return useContext(AuthContext);
}
export const AuthContext = createContext<AuthState>({ isLoading: true, isAuthenticated: false, user: null, error: null });
export function useAuth() { return useContext(AuthContext); }
+2 -4
View File
@@ -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<string> {
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();
+3 -4
View File
@@ -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<void> {
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();
+17 -78
View File
@@ -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<Set<string>>(new Set());
const [expandedAssetTypes, setExpandedAssetTypes] = useState<Set<string>>(new Set());
const [showPlateNumbers, setShowPlateNumbers] = useState<VehicleModalSelection | null>(null);
const closeAssetAnalysis = useCallback(() => setShowPlateNumbers(null), []);
// Data state
const [summary, setSummary] = useState<SummaryData | null>(null);
const [processedData, setProcessedData] = useState<TypeSummary[]>([]);
const [modalVehicles, setModalVehicles] = useState<VehicleListItem[]>([]);
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date()));
const [modalLoading, setModalLoading] = useState(false);
const [flowRange, setFlowRange] = useState(() => getRecentFlowRange());
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(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 (
<Suspense fallback={<div role="status" className="min-h-screen bg-[var(--app-bg)] p-6 text-slate-700">正在加载 AI 问数页面…</div>}>
<Suspense fallback={<div role="status" className="min-h-screen bg-[var(--app-bg)] p-6 text-slate-700">正在加载AI问数页面…</div>}>
<AssetAssistant key={selectedSubject || 'all'} subject={selectedSubject} appearance="light" close={closeAssistant} />
</Suspense>
);
@@ -1448,7 +1398,7 @@ export default function AssetsModule() {
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">业务负责人</label>
<select className="w-full bg-white border border-gray-200 rounded-lg py-2 px-2 text-xs focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 outline-none transition-all cursor-pointer shadow-sm" value={draftCustomerFilters.manager} onChange={(e) => setDraftCustomerFilters(prev => ({ ...prev, manager: e.target.value }))}>
<option value="">所有负责人</option>
<option value="">所有业务负责人</option>
{customerManagersGroupedByDept
.filter(g => g.department !== '公务车')
.filter(g => !draftCustomerFilters.department || g.department === draftCustomerFilters.department)
@@ -1502,7 +1452,7 @@ export default function AssetsModule() {
)}
{customerFilters.manager && (
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded text-[10px] flex items-center gap-1">
负责人: {customerFilters.manager}
业务负责人: {customerFilters.manager}
<button onClick={() => setCustomerFilters(prev => ({...prev, manager: ''}))} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
@@ -1720,28 +1670,17 @@ export default function AssetsModule() {
)}
<FlowDetailModal
<FlowAnalysisModal
selectedFlow={selectedFlow}
selectedFlowDetails={selectedFlowDetails}
setSelectedFlow={setSelectedFlow}
exportFlowDetails={exportFlowDetails}
/>
<VehicleDetailModal
<AssetAnalysisModal
selection={showPlateNumbers}
setSelection={setShowPlateNumbers}
isFilterExpanded={isModalFilterExpanded}
setIsFilterExpanded={setIsModalFilterExpanded}
filters={modalFilters}
setFilters={setModalFilters}
plates={uniqueModalPlates}
models={uniqueModalModels}
brands={uniqueModalBrands}
locations={uniqueModalLocations}
loading={modalLoading}
weeklyDetails={modalWeeklyDetail}
filteredWeeklyDetails={filteredModalWeeklyDetail}
filteredVehicles={filteredModalVehicles}
subject={selectedSubject}
close={closeAssetAnalysis}
/>
</div>
@@ -1750,11 +1689,11 @@ export default function AssetsModule() {
ref={assistantTrigger}
type="button"
onClick={openAssistant}
aria-label="进入 AI 问数页面"
aria-label="进入AI问数页面"
className="fixed bottom-[calc(5rem+env(safe-area-inset-bottom))] right-4 z-[60] inline-flex min-h-12 items-center gap-2 rounded-full bg-gradient-to-br from-blue-600 to-cyan-500 px-4 text-sm font-semibold text-white shadow-lg shadow-blue-200 transition hover:from-blue-700 hover:to-cyan-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 md:bottom-6 md:right-6"
>
<Sparkles size={20} aria-hidden="true" />
AI 问数
AI问数
</button>
</div>
);
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AssetAnalysisCell } from '../../shared/assets/analytics';
import {
aggregateAnalysisRows,
filterAnalysisCells,
filterCustomCells,
getAnalysisDimensions,
getAnalysisSlice,
getAvailableDimensions,
getCustomAnalysis,
getDefaultDimensions,
summarizeAnalyticsCells,
} from './analytics-model';
function cell(overrides: Partial<AssetAnalysisCell>): AssetAnalysisCell {
return {
type: '18T', model: '车型甲', region: '华东', city: '嘉兴市',
assetStatus: 'inventory', assetDisposition: '在库', operationKind: '可运营',
inventoryState: 'available', abnormalType: '不适用', abnormalAge: 'unknown',
department: '业务一部', manager: '负责人甲', customer: '客户甲', quantity: 1,
...overrides,
};
}
const cells = [
cell({ quantity: 5 }),
cell({ inventoryState: 'abnormal', abnormalType: '维修', abnormalAge: '30d+', quantity: 3 }),
cell({ region: '华南', city: '广州市', inventoryState: 'pending', quantity: 2 }),
cell({ assetStatus: 'operating', assetDisposition: '租赁', operationKind: '租赁', inventoryState: 'not-applicable', quantity: 10 }),
cell({ type: '49T', region: '华南', city: '深圳市', assetStatus: 'operating', assetDisposition: '自营', operationKind: '自营', inventoryState: 'not-applicable', quantity: 4 }),
cell({ assetStatus: 'other', assetDisposition: '其他', operationKind: '未分类', inventoryState: 'not-applicable', quantity: 1 }),
];
test('inventory subset counts reconcile at group and filtered-total levels without adding to inventory', () => {
assert.ok(!getAvailableDimensions('inventory').includes('manager'));
assert.ok(!getAvailableDimensions('inventory').includes('customer'));
assert.ok(getAvailableDimensions('operating').includes('manager'));
assert.ok(getAvailableDimensions('operating').includes('customer'));
const result = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region', 'type'] });
assert.equal(result.total, 10);
assert.equal(result.summary.pending, 2);
assert.equal(result.summary.abnormal, 3);
assert.equal(result.rows.reduce((sum, row) => sum + row.totals.pending, 0), 2);
assert.equal(result.rows.reduce((sum, row) => sum + row.totals.abnormal, 0), 3);
const filtered = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region', 'type'], filterValues: { region: ['华东'] } });
assert.equal(filtered.total, 8);
assert.equal(filtered.summary.pending, 0);
assert.equal(filtered.summary.abnormal, 3);
assert.equal(filtered.rows[0].totals.pending, 0);
assert.equal(filtered.rows[0].totals.abnormal, 3);
});
test('management totals reconcile without double-counting inventory subsets', () => {
const summary = summarizeAnalyticsCells(cells);
assert.equal(summary.assets, 25);
assert.equal(summary.operating, 14);
assert.equal(summary.inventory, 10);
assert.equal(summary.other, 1);
assert.equal(summary.abnormal, 3);
assert.equal(summary.pending, 2);
assert.equal(summary.abnormalRate, 30);
assert.equal(summary.pendingRate, 20);
assert.ok(Math.abs(summary.operatingRate - 56) < 1e-10);
});
test('operating drill keeps region, city, type and operation kind as aggregate levels', () => {
let slice = getAnalysisSlice(cells, { view: 'operating' });
assert.equal(slice.dimension, 'region');
assert.equal(slice.total, 14);
assert.deepEqual(slice.rows.map(row => row.count), [10, 4]);
assert.equal(slice.rows.reduce((sum, row) => sum + row.share, 0), 100);
for (const expectedDimension of ['city', 'type', 'operationKind']) {
assert.equal(slice.isLeaf, false);
slice = getAnalysisSlice(cells, { view: 'operating', path: slice.rows[0].nextPath });
assert.equal(slice.dimension, expectedDimension);
assert.equal(slice.total, 10);
}
slice = getAnalysisSlice(cells, { view: 'operating', path: slice.rows[0].nextPath });
assert.equal(slice.isLeaf, true);
assert.equal(slice.dimension, null);
assert.deepEqual(slice.rows, []);
assert.equal(summarizeAnalyticsCells(filterAnalysisCells(cells, slice.filters, slice.metric)).assets, slice.total);
});
test('asset uses mutually exclusive stock, lease and self-operated dispositions', () => {
const slice = getAnalysisSlice(cells, { view: 'assets', filters: { type: '18T' } });
assert.equal(slice.dimension, 'assetDisposition');
assert.equal(slice.rows.reduce((sum, row) => sum + row.count, 0), slice.total);
assert.deepEqual(slice.rows.map(row => row.value).sort(), ['其他', '在库', '租赁'].sort());
});
test('fixed global dimensions are skipped and conflicting stale paths cannot widen results', () => {
const slice = getAnalysisSlice(cells, {
view: 'operating', filters: { region: '华南', type: '49T' },
path: [{ dimension: 'region', value: '华南' }, { dimension: 'city', value: '深圳市' }],
});
assert.equal(slice.dimension, 'operationKind');
assert.equal(slice.total, 4);
assert.deepEqual(slice.path, [{ dimension: 'city', value: '深圳市' }]);
const conflict = getAnalysisSlice(cells, {
view: 'operating', filters: { region: '华南' }, path: [{ dimension: 'region', value: '华东' }],
});
assert.equal(conflict.total, 0);
assert.deepEqual(conflict.rows, []);
});
test('abnormal pivot retains all three dimensions once and ends at vehicle type', () => {
for (const start of ['abnormalType', 'region', 'abnormalAge'] as const) {
const dimensions = getAnalysisDimensions('abnormal', start);
assert.equal(dimensions[0], start);
assert.equal(dimensions.at(-1), 'type');
assert.equal(new Set(dimensions).size, dimensions.length);
}
const missingTime = cell({ inventoryState: 'abnormal', abnormalType: '维修', abnormalAge: 'unknown', quantity: 2 });
const slice = getAnalysisSlice([...cells, missingTime], { view: 'abnormal', abnormalStart: 'abnormalAge' });
assert.equal(slice.total, 5);
assert.equal(slice.rows.find(row => row.value === 'unknown')?.label, '审批时间缺失');
assert.equal(slice.rows.find(row => row.value === 'unknown')?.count, 2);
});
test('chronological label sort preserves all duration buckets without rounding totals', () => {
const durationCells = ['unknown', '30d+', '0-3d', '15-30d', '7-15d', '3-7d'].map(abnormalAge => cell({
inventoryState: 'abnormal', abnormalAge: abnormalAge as AssetAnalysisCell['abnormalAge'],
}));
const rows = aggregateAnalysisRows(durationCells, 'abnormalAge', 'abnormal', 'label');
assert.deepEqual(rows.map(row => row.value), ['0-3d', '3-7d', '7-15d', '15-30d', '30d+', 'unknown']);
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), 6);
assert.equal(summarizeAnalyticsCells([]).operatingRate, 0);
});
test('custom analysis combines independent dimensions and conserves the selected metric', () => {
const analysis = getCustomAnalysis(cells, { view: 'assets', dimensions: ['region', 'type'] });
assert.deepEqual(analysis.dimensions, ['region', 'type']);
assert.equal(analysis.total, 25);
assert.equal(analysis.rows.length, 3);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), analysis.total);
assert.equal(analysis.rows.find(row => row.values.region === '华东')?.count, 19);
assert.equal(analysis.rows.find(row => row.values.region === '华南' && row.values.type === '18T')?.count, 2);
assert.equal(analysis.rows.find(row => row.values.region === '华南' && row.values.type === '49T')?.count, 4);
assert.ok(Math.abs(analysis.rows.reduce((sum, row) => sum + row.share, 0) - 100) < 1e-10);
});
test('custom composite keys cannot collide when dimension values contain delimiters', () => {
const collisionCells = [
cell({ region: 'A · B', city: 'C', quantity: 2 }),
cell({ region: 'A', city: 'B · C', quantity: 3 }),
cell({ region: '["A","B"]', city: '"C"', quantity: 4 }),
];
const analysis = getCustomAnalysis(collisionCells, { view: 'assets', dimensions: ['region', 'city'] });
assert.equal(analysis.rows.length, 3);
assert.equal(new Set(analysis.rows.map(row => row.key)).size, 3);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), 9);
});
test('custom filters OR values within a dimension and AND across dimensions', () => {
const filterValues = { region: ['华东', '华南'], type: ['18T'], inventoryState: ['available', 'pending'] };
const filtered = filterCustomCells(cells, filterValues, 'inventory');
assert.equal(summarizeAnalyticsCells(filtered).inventory, 7);
const analysis = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region'], filterValues });
assert.equal(analysis.total, 7);
assert.deepEqual(analysis.rows.map(row => row.count), [5, 2]);
assert.equal(analysis.rows[0].share, 5 / 7 * 100);
assert.equal(getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region'], filterValues: { region: ['不存在区域'] } }).total, 0);
});
test('empty filter selections impose no restriction and filter-only analysis gives one total', () => {
const analysis = getCustomAnalysis(cells, { view: 'operating', dimensions: [], filterValues: { region: [] } });
assert.equal(analysis.total, 14);
assert.equal(analysis.rows.length, 1);
assert.equal(analysis.rows[0].label, '合计');
assert.equal(analysis.rows[0].count, 14);
assert.equal(analysis.rows[0].share, 100);
assert.deepEqual(analysis.rows[0].values, {});
assert.equal(analysis.summary.assets, 25);
assert.equal(getCustomAnalysis([], { view: 'operating', dimensions: [] }).rows.length, 0);
assert.equal(getCustomAnalysis([cell({})], { view: 'operating', dimensions: [] }).rows.length, 0);
});
test('custom shares use the selected metric rather than all assets in the scope', () => {
const analysis = getCustomAnalysis(cells, { view: 'operating', dimensions: ['region'] });
assert.equal(analysis.total, 14);
assert.equal(analysis.summary.assets, 25);
assert.equal(analysis.rows.find(row => row.values.region === '华东')?.share, 10 / 14 * 100);
assert.equal(analysis.rows.find(row => row.values.region === '华南')?.share, 4 / 14 * 100);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), analysis.total);
});
test('custom dimensions remove unknown, duplicate and inapplicable values without losing counts', () => {
const analysis = getCustomAnalysis(cells, {
view: 'operating',
dimensions: ['region', 'bogus', 'region', 'abnormalAge', 'type'] as Parameters<typeof getCustomAnalysis>[1]['dimensions'],
});
assert.deepEqual(analysis.dimensions, ['region', 'type']);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), 14);
for (const view of ['assets', 'operating', 'inventory', 'abnormal', 'pending', 'department', 'customer'] as const) {
assert.equal(getDefaultDimensions(view).length, 1);
assert.ok(getAvailableDimensions(view).includes(getDefaultDimensions(view)[0]));
}
});
test('custom sorting supports count order and semantic duration order', () => {
const ascending = getCustomAnalysis(cells, { view: 'assets', dimensions: ['region', 'type'], sort: 'count-asc' });
assert.deepEqual(ascending.rows.map(row => row.count), [2, 4, 19]);
const durationCells = ['unknown', '30d+', '0-3d'].map(abnormalAge => cell({ inventoryState: 'abnormal', abnormalAge: abnormalAge as AssetAnalysisCell['abnormalAge'] }));
const byLabel = getCustomAnalysis(durationCells, { view: 'abnormal', dimensions: ['abnormalAge'], sort: 'label' });
assert.deepEqual(byLabel.rows.map(row => row.values.abnormalAge), ['0-3d', '30d+', 'unknown']);
});
+365
View File
@@ -0,0 +1,365 @@
import type {
AssetAnalysisCell,
AssetAnalysisDimension,
AssetAnalysisFilters,
AssetAnalysisMetric,
} from '../../shared/assets/analytics';
import { ASSET_ANALYSIS_DIMENSIONS } from '../../shared/assets/analytics';
export type AnalysisView = AssetAnalysisMetric | 'department' | 'customer';
export type AnalysisSort = 'count-desc' | 'count-asc' | 'label';
export type AbnormalStartDimension = 'abnormalType' | 'region' | 'abnormalAge';
export interface AnalysisPathStep {
dimension: AssetAnalysisDimension;
value: string;
}
export interface AnalysisTotals {
assets: number;
operating: number;
inventory: number;
abnormal: number;
pending: number;
other: number;
/** All rates are percentages, not fractions. */
operatingRate: number;
inventoryRate: number;
/** Abnormal and pending are inventory subsets; their denominator is inventory. */
abnormalRate: number;
pendingRate: number;
}
export interface AnalysisAggregateRow {
value: string;
label: string;
count: number;
/** Share of the current metric total, as a percentage. */
share: number;
totals: AnalysisTotals;
}
export interface AnalysisRow extends AnalysisAggregateRow {
nextPath: AnalysisPathStep[];
isLeaf: boolean;
}
export interface AnalysisOptions {
view: AnalysisView;
filters?: AssetAnalysisFilters;
path?: readonly AnalysisPathStep[];
abnormalStart?: AbnormalStartDimension;
sort?: AnalysisSort;
}
export interface AnalysisSlice {
metric: AssetAnalysisMetric;
dimension: AssetAnalysisDimension | null;
dimensions: AssetAnalysisDimension[];
path: AnalysisPathStep[];
filters: AssetAnalysisFilters;
rows: AnalysisRow[];
total: number;
summary: AnalysisTotals;
isLeaf: boolean;
breadcrumbs: Array<AnalysisPathStep & { label: string }>;
}
export const ANALYSIS_META: Record<AnalysisView, {
title: string;
description: string;
metric: AssetAnalysisMetric;
unit: '台';
}> = {
assets: { title: '资产结构', description: '从车型看在库、租赁与自营结构,再定位区域和城市。', metric: 'assets', unit: '台' },
operating: { title: '运营分析', description: '按区域、城市、车型和运营状态逐层比较运营分布。', metric: 'operating', unit: '台' },
inventory: { title: '库存分析', description: '按区域、城市和车型定位库存。', metric: 'inventory', unit: '台' },
abnormal: { title: '异动分析', description: '交叉分析异动类型、区域与已异动时长,识别集中与积压。', metric: 'abnormal', unit: '台' },
pending: { title: '待交车分析', description: '按大区、城市和车型查看待交车分布。', metric: 'pending', unit: '台' },
department: { title: '部门分析', description: '从部门到业务负责人和车型,查看运营资产配置。', metric: 'operating', unit: '台' },
customer: { title: '客户分析', description: '从客户到车型和区域,查看运营资产集中度。', metric: 'operating', unit: '台' },
};
export const DIMENSION_LABELS: Record<AssetAnalysisDimension, string> = {
type: '车型', model: '型号', region: '区域', city: '城市',
assetStatus: '资产状态', assetDisposition: '资产用途', operationKind: '运营状态',
inventoryState: '库存状态', abnormalType: '异动类型', abnormalAge: '已异动时长',
department: '部门', manager: '业务负责人', customer: '客户',
};
const VALUE_LABELS: Partial<Record<AssetAnalysisDimension, Record<string, string>>> = {
assetStatus: { operating: '运营中', inventory: '在库', other: '其他状态' },
inventoryState: {
abnormal: '异动', pending: '待交车', available: '可运营', awaiting: '待运营',
unclassified: '未分类库存', 'not-applicable': '不适用',
},
abnormalAge: {
'0-3d': '不足 3 天', '3-7d': '3–7 天', '7-15d': '7–15 天',
'15-30d': '15–30 天', '30d+': '30 天及以上', unknown: '审批时间缺失',
},
};
const DIMENSION_ORDER: Partial<Record<AssetAnalysisDimension, readonly string[]>> = {
region: ['华东', '华南', '华北', '华中', '西南', '西北', '其他'],
abnormalAge: ['0-3d', '3-7d', '7-15d', '15-30d', '30d+', 'unknown'],
assetStatus: ['inventory', 'operating', 'other'],
assetDisposition: ['在库', '租赁', '自营', '其他运营', '其他'],
inventoryState: ['available', 'awaiting', 'pending', 'abnormal', 'unclassified', 'not-applicable'],
};
const PATHS: Record<Exclude<AnalysisView, 'abnormal'>, readonly AssetAnalysisDimension[]> = {
assets: ['type', 'assetDisposition', 'region', 'city'],
operating: ['region', 'city', 'type', 'operationKind'],
inventory: ['region', 'city', 'type', 'inventoryState'],
pending: ['region', 'city', 'type'],
department: ['department', 'manager', 'type'],
customer: ['customer', 'type', 'region'],
};
export function getAnalysisDimensions(
view: AnalysisView,
abnormalStart: AbnormalStartDimension = 'abnormalType',
): AssetAnalysisDimension[] {
if (view !== 'abnormal') return [...PATHS[view]];
const comparisons: AbnormalStartDimension[] = ['abnormalType', 'region', 'abnormalAge'];
return [abnormalStart, ...comparisons.filter(dimension => dimension !== abnormalStart), 'type'];
}
export function getDimensionValueLabel(dimension: AssetAnalysisDimension, value: string): string {
return VALUE_LABELS[dimension]?.[value] ?? (value || '未标注');
}
/** Counts derive from the same mutually exclusive state dimensions as the server. */
export function cellMatchesMetric(cell: AssetAnalysisCell, metric: AssetAnalysisMetric): boolean {
switch (metric) {
case 'assets': return true;
case 'operating': return cell.assetStatus === 'operating';
case 'inventory': return cell.assetStatus === 'inventory';
case 'abnormal': return cell.assetStatus === 'inventory' && cell.inventoryState === 'abnormal';
case 'pending': return cell.assetStatus === 'inventory' && cell.inventoryState === 'pending';
}
}
export function filterAnalysisCells(
cells: readonly AssetAnalysisCell[],
filters: AssetAnalysisFilters = {},
metric: AssetAnalysisMetric = 'assets',
): AssetAnalysisCell[] {
const entries = Object.entries(filters).filter(([, value]) => value !== undefined && value !== '') as Array<[AssetAnalysisDimension, string]>;
return cells.filter(cell => cellMatchesMetric(cell, metric) && entries.every(([dimension, value]) => cell[dimension] === value));
}
const percent = (count: number, total: number): number => total > 0 ? count / total * 100 : 0;
export function summarizeAnalyticsCells(cells: readonly AssetAnalysisCell[]): AnalysisTotals {
const totals: AnalysisTotals = {
assets: 0, operating: 0, inventory: 0, abnormal: 0, pending: 0, other: 0,
operatingRate: 0, inventoryRate: 0, abnormalRate: 0, pendingRate: 0,
};
for (const cell of cells) {
totals.assets += cell.quantity;
if (cell.assetStatus === 'operating') totals.operating += cell.quantity;
else if (cell.assetStatus === 'inventory') {
totals.inventory += cell.quantity;
if (cell.inventoryState === 'abnormal') totals.abnormal += cell.quantity;
if (cell.inventoryState === 'pending') totals.pending += cell.quantity;
} else totals.other += cell.quantity;
}
totals.operatingRate = percent(totals.operating, totals.assets);
totals.inventoryRate = percent(totals.inventory, totals.assets);
totals.abnormalRate = percent(totals.abnormal, totals.inventory);
totals.pendingRate = percent(totals.pending, totals.inventory);
return totals;
}
export function aggregateAnalysisRows(
cells: readonly AssetAnalysisCell[],
dimension: AssetAnalysisDimension,
metric: AssetAnalysisMetric = 'assets',
sort: AnalysisSort = 'count-desc',
): AnalysisAggregateRow[] {
const groups = new Map<string, AssetAnalysisCell[]>();
for (const cell of cells) {
const value = cell[dimension];
const group = groups.get(value);
if (group) group.push(cell);
else groups.set(value, [cell]);
}
const total = summarizeAnalyticsCells(cells)[metric];
const rows: AnalysisAggregateRow[] = [];
for (const [value, group] of groups) {
const totals = summarizeAnalyticsCells(group);
const count = totals[metric];
if (count > 0) rows.push({ value, label: getDimensionValueLabel(dimension, value), count, share: percent(count, total), totals });
}
const compareLabels = (a: AnalysisAggregateRow, b: AnalysisAggregateRow) => {
const order = DIMENSION_ORDER[dimension];
if (order) {
const aIndex = order.indexOf(a.value);
const bIndex = order.indexOf(b.value);
const result = (aIndex < 0 ? order.length : aIndex) - (bIndex < 0 ? order.length : bIndex);
if (result !== 0) return result;
}
return a.label.localeCompare(b.label, 'zh-CN', { numeric: true });
};
return rows.sort((a, b) => {
const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0;
return difference || compareLabels(a, b);
});
}
/** Keep global filters and drill selections intersected; a stale path cannot widen scope. */
export function getAnalysisSlice(cells: readonly AssetAnalysisCell[], options: AnalysisOptions): AnalysisSlice {
const { view, abnormalStart = 'abnormalType', sort = 'count-desc' } = options;
const metric = ANALYSIS_META[view].metric;
const filters: AssetAnalysisFilters = {};
for (const [key, value] of Object.entries(options.filters ?? {})) {
if (value !== undefined && value !== '') filters[key as AssetAnalysisDimension] = value;
}
const dimensions = getAnalysisDimensions(view, abnormalStart).filter(dimension => filters[dimension] === undefined);
const path: AnalysisPathStep[] = [];
let conflictingPath = false;
for (const step of options.path ?? []) {
const globalValue = filters[step.dimension];
if (globalValue !== undefined) {
if (globalValue !== step.value) conflictingPath = true;
continue;
}
// Only a prefix of this analysis schema is valid. Unrelated old paths are discarded.
if (dimensions[path.length] !== step.dimension) break;
path.push({ dimension: step.dimension, value: step.value });
}
for (const step of path) filters[step.dimension] = step.value;
const scopedCells = conflictingPath ? [] : filterAnalysisCells(cells, filters);
const summary = summarizeAnalyticsCells(scopedCells);
const dimension = dimensions[path.length] ?? null;
const isLeaf = dimension === null;
const rows: AnalysisRow[] = dimension === null ? [] : aggregateAnalysisRows(scopedCells, dimension, metric, sort).map(row => ({
...row,
nextPath: [...path, { dimension, value: row.value }],
isLeaf: path.length + 1 === dimensions.length,
}));
return {
metric, dimension, dimensions, path, filters, rows, total: summary[metric], summary, isLeaf,
breadcrumbs: path.map(step => ({ ...step, label: getDimensionValueLabel(step.dimension, step.value) })),
};
}
export type AnalysisFilterValues = Partial<Record<AssetAnalysisDimension, string[]>>;
export interface CustomAnalysisRow {
key: string;
values: Partial<Record<AssetAnalysisDimension, string>>;
label: string;
count: number;
share: number;
totals: AnalysisTotals;
}
export interface CustomAnalysisOptions {
view: AnalysisView;
dimensions: readonly AssetAnalysisDimension[];
filterValues?: AnalysisFilterValues;
sort?: AnalysisSort;
}
export interface CustomAnalysis {
rows: CustomAnalysisRow[];
total: number;
summary: AnalysisTotals;
dimensions: AssetAnalysisDimension[];
metric: AssetAnalysisMetric;
}
const COMMON_DIMENSIONS: readonly AssetAnalysisDimension[] = [
'type', 'model', 'region', 'city', 'department', 'manager', 'customer',
];
const BUSINESS_DIMENSIONS: Record<AnalysisView, readonly AssetAnalysisDimension[]> = {
assets: ['assetDisposition', 'operationKind'],
operating: ['operationKind'],
inventory: ['operationKind'],
abnormal: ['abnormalType', 'abnormalAge'],
pending: [],
department: ['operationKind'],
customer: ['operationKind'],
};
/** Dimensions are choices, not a required drill sequence. */
export function getAvailableDimensions(view: AnalysisView): AssetAnalysisDimension[] {
const common = view === 'inventory'
? COMMON_DIMENSIONS.filter(dimension => dimension !== 'manager' && dimension !== 'customer')
: COMMON_DIMENSIONS;
return [...common, ...BUSINESS_DIMENSIONS[view]];
}
export function getDefaultDimensions(view: AnalysisView): AssetAnalysisDimension[] {
if (view === 'assets') return ['type'];
if (view === 'abnormal') return ['abnormalType'];
if (view === 'department') return ['department'];
if (view === 'customer') return ['customer'];
return ['region'];
}
/** Multiple values of one dimension are OR; selections across dimensions are AND. */
export function filterCustomCells(
cells: readonly AssetAnalysisCell[],
filterValues: AnalysisFilterValues = {},
metric: AssetAnalysisMetric = 'assets',
): AssetAnalysisCell[] {
const constraints: Array<[AssetAnalysisDimension, Set<string>]> = [];
for (const dimension of ASSET_ANALYSIS_DIMENSIONS) {
const selected = filterValues[dimension];
if (!Array.isArray(selected) || selected.length === 0) continue;
constraints.push([dimension, new Set(selected)]);
}
return cells.filter(cell => cellMatchesMetric(cell, metric)
&& constraints.every(([dimension, selected]) => selected.has(cell[dimension])));
}
export function compareCustomLabels(a: CustomAnalysisRow, b: CustomAnalysisRow, dimensions: readonly AssetAnalysisDimension[]): number {
for (const dimension of dimensions) {
const aValue = a.values[dimension]!;
const bValue = b.values[dimension]!;
const order = DIMENSION_ORDER[dimension];
if (order) {
const aIndex = order.indexOf(aValue);
const bIndex = order.indexOf(bValue);
const difference = (aIndex < 0 ? order.length : aIndex) - (bIndex < 0 ? order.length : bIndex);
if (difference !== 0) return difference;
}
const difference = getDimensionValueLabel(dimension, aValue).localeCompare(getDimensionValueLabel(dimension, bValue), 'zh-CN', { numeric: true });
if (difference !== 0) return difference;
}
return a.key.localeCompare(b.key);
}
export function getCustomAnalysis(cells: readonly AssetAnalysisCell[], options: CustomAnalysisOptions): CustomAnalysis {
const metric = ANALYSIS_META[options.view].metric;
const available = new Set(getAvailableDimensions(options.view));
const dimensions = [...new Set(options.dimensions.filter(dimension => available.has(dimension)))];
const scopedCells = filterCustomCells(cells, options.filterValues);
const summary = summarizeAnalyticsCells(scopedCells);
const total = summary[metric];
const groups = new Map<string, { values: CustomAnalysisRow['values']; cells: AssetAnalysisCell[] }>();
for (const cell of scopedCells) {
// Structured JSON preserves values containing separators or quotes.
const entries = dimensions.map(dimension => [dimension, cell[dimension]] as const);
const key = JSON.stringify(entries);
const group = groups.get(key);
if (group) group.cells.push(cell);
else groups.set(key, { values: Object.fromEntries(entries), cells: [cell] });
}
const rows: CustomAnalysisRow[] = [];
for (const [key, group] of groups) {
const totals = summarizeAnalyticsCells(group.cells);
const count = totals[metric];
if (count <= 0) continue;
const label = dimensions.length === 0 ? '合计' : dimensions.map(dimension => getDimensionValueLabel(dimension, group.values[dimension]!)).join(' · ');
rows.push({ key, values: group.values, label, count, share: percent(count, total), totals });
}
const sort = options.sort ?? 'count-desc';
rows.sort((a, b) => {
const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0;
return difference || compareCustomLabels(a, b, dimensions);
});
return { rows, total, summary, dimensions, metric };
}
@@ -398,7 +398,7 @@ export default function AssetAssistant({
<section
ref={page}
className={`aq-shell aq-page aq-${theme}`}
aria-label="资产 AI 问数"
aria-label="AI问数"
>
<aside className="aq-sidebar">
<div className="aq-brand">
@@ -406,8 +406,7 @@ export default function AssetAssistant({
<Sparkles size={19} />
</div>
<div>
<strong>羚牛 · 资产智能</strong>
<span>ASSET INTELLIGENCE</span>
<strong>AI问数</strong>
</div>
</div>
<button className="aq-new" onClick={reset}>
@@ -423,7 +422,7 @@ export default function AssetAssistant({
}}
>
<MessageSquare size={16} />
AI 问数<span className="aq-beta">BETA</span>
AI问数<span className="aq-beta">BETA</span>
</button>
<button className="aq-nav" onClick={close}>
<BarChart3 size={16} />
@@ -471,9 +470,9 @@ export default function AssetAssistant({
<header className="aq-header">
<div>
<Sparkles size={17} />
<h1>AI 问数</h1>
<h1>AI问数</h1>
<span className="aq-header-divider" />
<span className="aq-header-subtitle">你的资产分析助手</span>
<span className="aq-header-subtitle">用对话分析资产数据</span>
</div>
<div>
<button
@@ -562,10 +561,6 @@ export default function AssetAssistant({
</button>
))}
</div>
<div className="aq-welcome-note">
<Database size={14} />
只读查询 · 真实数据 · 可追溯明细
</div>
</div>
) : (
<div className="aq-turns">
@@ -396,15 +396,6 @@
font-size: 11px;
margin-top: 5px;
}
.aq-welcome-note {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 11px;
color: var(--aq-muted);
margin-top: 24px;
}
.aq-turns {
max-width: 820px;
margin: 0 auto;
@@ -1134,10 +1125,6 @@
padding: 14px;
min-height: 110px;
}
.aq-welcome-note {
font-size: 10px;
margin-top: 18px;
}
.aq-turns {
padding: 19px 16px 24px;
}
@@ -1298,9 +1285,6 @@
.aq-prompt-grid > button > div {
margin-bottom: 8px;
}
.aq-welcome-note {
margin-top: 14px;
}
}
/* Keep the assistant above the global feedback launcher, below the account watermark. */
.aq-shell {
@@ -2090,8 +2074,6 @@
.aq-prompt-grid > button > div > svg:last-child { display: none; }
.aq-prompt-grid > button strong { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
.aq-prompt-grid > button > span { display: none; }
.aq-welcome-note { margin-top: 12px; font-size: 12px; line-height: 1.5; flex-wrap: nowrap; }
.aq-welcome-note svg { flex-shrink: 0; }
}
@media (prefers-reduced-motion: reduce) {
.aq-page .aq-spin, .aq-page .aq-shimmer, .aq-page .aq-trace li { animation: none; }
@@ -138,7 +138,7 @@ test("copy includes regional facts and daily flow without duplicating groups or
};
const regionalText = copyResultText(regional, null);
assert.match(regionalText, /华东:99台,占比73.3%/);
assert.match(regionalText, /其中异动1台,其中待交车0台/);
assert.match(regionalText, /异动1台,待交车0台/);
assert.match(regionalText, /分析发现:[\s\S]*华东占当前可运营库存73.3%/);
assert.ok(!regionalText.includes("测试车牌不应复制"));
@@ -137,7 +137,7 @@ export function copyResultText(
"分组统计:",
...result.groups.map(
(group) =>
`${group.name}:${group.count}台${group.share === undefined ? "" : `,占比${group.share}%`},运营率${group.rate}%${group.abnormal === undefined ? "" : `,其中异动${group.abnormal}台`}${group.pending === undefined ? "" : `,其中待交车${group.pending}台`}`,
`${group.name}:${group.count}台${group.share === undefined ? "" : `,占比${group.share}%`},运营率${group.rate}%${group.abnormal === undefined ? "" : `,异动${group.abnormal}台`}${group.pending === undefined ? "" : `,待交车${group.pending}台`}`,
),
]
: []),
@@ -5,11 +5,13 @@ type DetailsProps = { vehicles: VehicleListItem[] };
function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) {
const pending = kind === 'pending';
const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '具体工作', '申请人', '目标完成时间', '实际完成时间'];
const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '审批通过时间', '已异动时长', '具体工作', '申请人', '预计完成时间', '实际完成时间'];
const details = (vehicle: VehicleListItem) => pending
? [vehicle.pendingDelivery?.contractNo || '未提供', vehicle.pendingDelivery?.customerName || '未提供']
: [
vehicle.abnormalMove?.type || '未提供',
vehicle.abnormalMove?.approvalPassedAt ? new Date(vehicle.abnormalMove.approvalPassedAt).toLocaleString('zh-CN', { hour12: false }) : '审批时间缺失',
vehicle.abnormalMove?.elapsedDays != null ? `${vehicle.abnormalMove.elapsedDays.toFixed(1)} 天` : '无法计算',
vehicle.abnormalMove?.work || '未提供',
vehicle.abnormalMove?.applicant || '未提供',
vehicle.abnormalMove?.targetCompletionTime || '未提供',
@@ -0,0 +1,159 @@
import { useCallback, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { ArrowLeft, Download, X } from 'lucide-react';
import type { FlowDetailItem, FlowType } from '../api';
import type { FlowSelection } from '../model';
import { FLOW_META } from './FlowDetailModal';
import { orderDimensionGroups, dimensionRowSpans, dimensionParentTotals, dimensionChildCounts, dimensionCountUnit, visibleDimensionRowSpans } from '../dimension-layout';
import { useDialogFocus } from './useDialogFocus';
import { DimensionPicker, type DimensionPickerOption } from './analysis/DimensionPicker';
import { ProgressiveRowsStatus } from './analysis/ProgressiveRowsStatus';
import { useProgressiveRows } from './analysis/useProgressiveRows';
import './flow-analysis-modal.css';
interface FlowAnalysisModalProps {
selectedFlow: FlowSelection | null;
selectedFlowDetails: FlowDetailItem[];
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
}
type Dimension = 'date' | 'department' | 'manager' | 'type';
const DIMENSIONS: { key: Dimension; label: string }[] = [
{ key: 'date', label: '日期' }, { key: 'department', label: '部门' },
{ key: 'manager', label: '业务负责人' }, { key: 'type', label: '业务类型' },
];
const DIMENSION_LABELS: Record<Dimension, string> = { date: '日期', department: '部门', manager: '业务负责人', type: '业务类型' };
const EMPTY_FLOW_ROWS: FlowDetailItem[] = [];
const countLabel = (count: number) => count.toLocaleString('zh-CN');
const dimensionValue = (row: FlowDetailItem, dimension: Dimension) => row[dimension].trim();
const dimensionLabel = (dimension: Dimension, value: string) => dimension === 'type'
? FLOW_META[value as FlowType]?.label || value
: value || `未填写${DIMENSION_LABELS[dimension]}`;
interface FlowGroup { key: string; values: string[]; rows: FlowDetailItem[] }
/** Each business event belongs to exactly one actual type; replacements also belong to the delivery scope. */
function matchesType(row: FlowDetailItem, type: FlowType): boolean {
return row.type === type || (type === 'delivered' && row.type === 'replaced');
}
function groupRows(rows: FlowDetailItem[], dimensions: Dimension[]): FlowGroup[] {
const groups = new Map<string, FlowGroup>();
for (const row of rows) {
const values = dimensions.map(dimension => dimensionValue(row, dimension));
const key = JSON.stringify(values);
const group = groups.get(key) ?? { key, values, rows: [] };
group.rows.push(row);
groups.set(key, group);
}
return Array.from(groups.values()).sort((a, b) => b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
}
export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
if (!props.selectedFlow) return null;
return <FlowAnalysisContent key={JSON.stringify(props.selectedFlow)} {...props} selectedFlow={props.selectedFlow} />;
}
function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow, exportFlowDetails }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
const [mergeParents, setMergeParents] = useState(true);
const mergeActive = mergeParents && dimensions.length > 1;
const [filters, setFilters] = useState<Partial<Record<Dimension, string[]>>>({});
const [detailKey, setDetailKey] = useState<string | null>(null);
const dialog = useRef<HTMLDivElement>(null);
const scrollRoot = useRef<HTMLDivElement>(null);
const close = useCallback(() => setSelectedFlow(null), [setSelectedFlow]);
useDialogFocus(true, dialog, close);
const rows = useMemo(() => selectedFlowDetails.filter(row => matchesType(row, selectedFlow.type)
&& ('date' in selectedFlow ? row.date === selectedFlow.date : row.date >= selectedFlow.start && row.date <= selectedFlow.end)), [selectedFlowDetails, selectedFlow]);
const filterOptions = useMemo(() => Object.fromEntries(DIMENSIONS.map(({ key }) => {
const eligible = rows.filter(row => DIMENSIONS.every(({ key: other }) => other === key || !filters[other]?.length || filters[other]!.includes(dimensionValue(row, other))));
const counts = new Map<string, number>();
for (const row of eligible) { const value = dimensionValue(row, key); counts.set(value, (counts.get(value) || 0) + 1); }
for (const value of filters[key] || []) if (!counts.has(value)) counts.set(value, 0);
return [key, [...counts].map(([value, count]) => ({ value, label: dimensionLabel(key, value), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))];
})) as Record<Dimension, DimensionPickerOption[]>, [rows, filters]);
const filteredRows = useMemo(() => rows.filter(row => DIMENSIONS.every(({ key }) => !filters[key]?.length || filters[key]!.includes(dimensionValue(row, key)))), [rows, filters]);
const groups = useMemo(() => {
const flat = groupRows(filteredRows, dimensions);
return mergeActive ? orderDimensionGroups(flat, dimensions.length, group => group.values, group => group.rows.length) : flat;
}, [filteredRows, dimensions, mergeActive]);
const detailGroup = useMemo(() => detailKey === null ? null : groups.find(group => group.key === detailKey), [groups, detailKey]);
const detailRows = detailGroup?.rows ?? EMPTY_FLOW_ROWS;
const showDetails = detailKey !== null;
const detailLabel = detailGroup && dimensions.length ? detailGroup.values.map((value, i) => `${DIMENSION_LABELS[dimensions[i]]}:${dimensionLabel(dimensions[i], value)}`).join(' · ') : '当前筛选范围';
const selectedRows = showDetails ? detailRows : filteredRows;
const { replacedCount, departmentCount } = useMemo(() => ({
replacedCount: selectedRows.reduce((sum, row) => sum + Number(row.type === 'replaced'), 0),
departmentCount: new Set(selectedRows.map(row => dimensionValue(row, 'department'))).size,
}), [selectedRows]);
const rangeLabel = 'date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`;
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 activeFilters = DIMENSIONS.filter(({ key }) => filters[key]?.length);
const resetResult = () => { setDetailKey(null); };
const toggleDimension = (dimension: Dimension) => {
setDimensions(current => current.includes(dimension) ? current.filter(value => value !== dimension) : [...current, dimension]);
resetResult();
};
const changeFilter = (dimension: Dimension, value: string[] | undefined) => {
setFilters(current => { const next = { ...current }; if (value === undefined) delete next[dimension]; else next[dimension] = value; return next; });
resetResult();
};
return <div className="bi-flow-modal-backdrop" onClick={event => { if (event.target === event.currentTarget) close(); }}>
<div ref={dialog} role="dialog" aria-modal="true" aria-labelledby="bi-flow-modal-title" tabIndex={-1} className="bi-flow-modal" data-testid="asset-flow-analysis-modal">
<header className="bi-flow-modal-header">
<div className="bi-flow-modal-header-row"><div><h3 id="bi-flow-modal-title">{FLOW_META[selectedFlow.type].label}分析</h3><p>{rangeLabel} · 运维签章时间 · 车次</p></div><button type="button" aria-label="关闭交还车分析" onClick={close} className="bi-flow-modal-close"><X size={16} /></button></div>
<p className="bi-flow-modal-definition">交车包含替换交车;业务类型按实际记录拆分,同车多笔业务分别计次。</p>
</header>
<div ref={scrollRoot} className="bi-flow-modal-body">
{!showDetails ? <div className="bi-flow-modal-controls">
<div className="bi-flow-modal-control-row"><span className="bi-flow-modal-control-label">分析维度</span><div className="adp-row" role="group" aria-label="选择交还车统计维度">
{DIMENSIONS.map(({ key, label }) => <DimensionPicker key={key} label={label} grouped={dimensions.includes(key)} order={dimensions.indexOf(key) + 1} options={filterOptions[key]} selectedValues={filters[key]} onToggleGroup={() => toggleDimension(key)} onApplyFilter={values => changeFilter(key, values)} />)}
<button type="button" className={`bi-flow-modal-chip${dimensions.length === 0 ? ' bi-flow-modal-chip-active' : ''}`} aria-pressed={dimensions.length === 0} onClick={() => { setDimensions([]); resetResult(); }}>仅筛选</button>
{activeFilters.length ? <button type="button" className="bi-flow-modal-clear" onClick={() => { setFilters({}); resetResult(); }}>清空筛选</button> : null}
</div></div>
<p className="bi-flow-modal-control-hint">点击维度名切换分组,点击右侧箭头筛选;同维度多选为“或”,不同维度为“且”。</p>
</div> : <div className="bi-flow-modal-detail-nav"><button type="button" className="bi-flow-modal-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={13} />返回统计</button><span>{detailLabel}</span></div>}
<div className="bi-flow-modal-summary">
<div><span>{showDetails ? '当前分组' : '当前筛选'}{FLOW_META[selectedFlow.type].label}</span><strong>{countLabel(selectedRows.length)}<small>车次</small></strong></div>
<div><span>涉及部门</span><strong>{departmentCount}<small>个</small></strong></div>
{selectedFlow.type === 'delivered' ? <div><span>其中:替换交车</span><strong className="bi-flow-modal-purple">{countLabel(replacedCount)}<small>车次</small></strong></div> : <div><span>{showDetails ? '占当前筛选' : '统计分组'}</span><strong>{showDetails ? `${filteredRows.length ? (selectedRows.length / filteredRows.length * 100).toFixed(1) : '0.0'}%` : groups.length}{!showDetails ? <small>组</small> : null}</strong></div>}
</div>
<div className="bi-flow-modal-section-heading"><div><h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4><p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
{!showDetails && dimensions.length > 1 ? <label className="bi-flow-modal-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label> : null}
{showDetails ? <button type="button" disabled={!detailRows.length} className="bi-flow-modal-export" onClick={() => exportFlowDetails(detailRows, `${FLOW_META[selectedFlow.type].label}-${detailLabel}-明细`)}><Download size={13} />导出当前明细</button> : null}
</div>
{!showDetails ? groups.length ? <>
<div className={`bi-flow-modal-table-wrap${mergeActive ? ' is-merged' : ''}`}><table className="bi-flow-modal-table" data-testid="asset-flow-group-table"><thead><tr>{dimensions.length ? dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>范围</th>}<th className="bi-flow-modal-number">车次</th><th className="bi-flow-modal-share">占比</th><th className="bi-flow-modal-action">操作</th></tr></thead><tbody>
{shownGroups.map((group, rowIndex) => <tr key={group.key} className={mergeActive && groupSpans[rowIndex][0] > 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 : <td key={dimensions[index]} rowSpan={span > 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 && <><small className="bi-flow-modal-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{countLabel(parentTotals[rowIndex][index])}</strong> 车次</small><small className="bi-flow-modal-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{DIMENSION_LABELS[dimensions[index + 1]]}:<strong>{countLabel(childCounts[rowIndex][index])}</strong> {dimensionCountUnit(dimensions[index + 1])}</small></>}</td>;
}) : <td>当前筛选范围</td>}
<td className="bi-flow-modal-number"><strong>{countLabel(group.rows.length)}</strong></td><td className="bi-flow-modal-share"><div><span className="bi-flow-modal-track"><span className={`bi-flow-modal-fill bi-flow-modal-fill-${selectedFlow.type}`} style={{ width: `${group.rows.length / filteredRows.length * 100}%` }} /></span><span>{(group.rows.length / filteredRows.length * 100).toFixed(1)}%</span></div></td>
<td className="bi-flow-modal-action"><button type="button" aria-label={`查看${group.values.map((value, i) => dimensionLabel(dimensions[i], value)).join('、') || '当前筛选范围'}明细`} onClick={() => { setDetailKey(group.key); }}>查看明细</button></td>
</tr>)}
</tbody></table></div>
<ProgressiveRowsStatus {...groupProgress} unit="组" />
</> : <div className="bi-flow-modal-empty">当前筛选范围暂无交还车数据,请调整筛选。</div>
: detailRows.length ? <>
<div className="bi-flow-modal-table-wrap" data-testid="asset-flow-final-details"><table className="bi-flow-modal-table bi-flow-modal-detail-table"><thead><tr><th>车牌</th><th>业务类型</th><th>运维签章时间</th><th>建单时间</th><th>部门</th><th>业务负责人</th><th>客户</th></tr></thead><tbody>{shownDetails.map(item => <tr key={item.id}><td>{item.plateNumber || '未登记车牌'}</td><td>{FLOW_META[item.type].label}</td><td>{item.eventTime || '—'}</td><td>{item.submitTime || '—'}</td><td>{dimensionLabel('department', dimensionValue(item, 'department'))}</td><td>{item.manager || '—'}</td><td>{item.customerName || '—'}</td></tr>)}</tbody></table></div>
<ProgressiveRowsStatus {...detailProgress} unit="条" />
</> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>}
</div>
</div>
</div>;
}
@@ -46,7 +46,7 @@ export function FlowDetailModal({
{dateLabel} · {FLOW_META[selectedFlow.type].label}明细
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、负责人和客户
共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、业务负责人和客户
</div>
</div>
<button
@@ -120,7 +120,7 @@ export function FlowDetailModal({
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">负责人</div>
<div className="font-black text-slate-400">业务负责人</div>
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
@@ -0,0 +1,89 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, Filter, Plus, RotateCcw, Search, X } from 'lucide-react';
import type { AssetAnalysisCell, AssetAnalysisDimension as Dimension } from '../../../../shared/assets/analytics';
import { ANALYSIS_META, DIMENSION_LABELS, filterCustomCells, getDimensionValueLabel, type AnalysisFilterValues, type AnalysisView } from '../../analytics-model';
type Props = {
view: AnalysisView;
cells: AssetAnalysisCell[];
available: Dimension[];
dimensions: Dimension[];
setDimensions: (value: Dimension[]) => void;
filters: AnalysisFilterValues;
setFilters: (value: AnalysisFilterValues) => void;
reset: () => void;
};
export function AnalysisControls({ view, cells, available, dimensions, setDimensions, filters, setFilters, reset }: Props) {
const root = useRef<HTMLElement>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const [editor, setEditor] = useState<Dimension | null>(null);
const [draft, setDraft] = useState<string[]>([]);
const [query, setQuery] = useState('');
const activeCount = Object.values(filters).filter(values => values?.length).length;
useEffect(() => {
if (!editor) return;
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setEditor(null); };
document.addEventListener('pointerdown', outside);
return () => document.removeEventListener('pointerdown', outside);
}, [editor]);
const metric = ANALYSIS_META[view].metric;
const options = useMemo(() => {
if (!editor) return [];
const otherFilters = { ...filters };
delete otherFilters[editor];
const eligible = filterCustomCells(cells, otherFilters, metric);
const counts = new Map<string, number>();
eligible.forEach(cell => counts.set(cell[editor], (counts.get(cell[editor]) || 0) + cell.quantity));
for (const value of filters[editor] || []) if (!counts.has(value)) counts.set(value, 0);
return [...counts].map(([value, count]) => ({ value, count, label: getDimensionValueLabel(editor, value) }))
.sort((a,b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))
.filter(option => option.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
}, [cells, editor, filters, metric, query]);
const closeEditor = () => { setEditor(null); triggerRef.current?.focus(); };
const open = (dimension: Dimension, trigger: HTMLButtonElement) => {
triggerRef.current = trigger;
if (editor === dimension) { setEditor(null); return; }
const bounds = root.current!.getBoundingClientRect();
const button = trigger.getBoundingClientRect();
setPosition({ top: button.bottom - bounds.top + 5, left: Math.max(8, Math.min(button.left - bounds.left, bounds.width - 338)) });
setEditor(dimension); setDraft(filters[dimension] || []); setQuery('');
};
const apply = () => {
if (!editor) return;
const next = { ...filters };
if (draft.length) next[editor] = draft; else delete next[editor];
setFilters(next); closeEditor();
};
return <section ref={root} className="analysis-builder" aria-label="自定义分析设置">
<div className="analysis-builder-heading"><h3>分析维度</h3><span>点名称组合统计 · 点箭头搜索筛选</span><button className="analysis-reset" onClick={() => { reset(); setEditor(null); }}><RotateCcw size={12} />重置分析</button></div>
<div className="analysis-dimension-options" role="group" aria-label="选择分析维度">
<button aria-pressed={dimensions.length === 0} onClick={() => { setDimensions([]); setEditor(null); }}><Filter size={12} />仅筛选</button>
{available.map(dimension => {
const index = dimensions.indexOf(dimension);
const selected = filters[dimension] || [];
const filterLabel = selected.map(value => getDimensionValueLabel(dimension, value)).join('、');
return <div className={`analysis-dimension-column${selected.length ? ' has-filter' : ''}`} key={dimension}>
<button className="analysis-dimension-name" title={dimension === 'region' ? '区域划分与库存统计一致' : undefined} aria-pressed={index >= 0} onClick={() => { setDimensions(index < 0 ? [...dimensions, dimension] : dimensions.filter(item => item !== dimension)); setEditor(null); }}>
{index >= 0 ? <span className="analysis-dimension-order">{index + 1}</span> : <Plus size={12} />}{DIMENSION_LABELS[dimension]}
</button>
<button className="analysis-dimension-dropdown" title={selected.length ? filterLabel : `搜索、筛选${DIMENSION_LABELS[dimension]}`} aria-label={`筛选${DIMENSION_LABELS[dimension]}${selected.length ? `,已选${selected.length}项` : ''}`} aria-expanded={editor === dimension} aria-controls={editor === dimension ? 'analysis-column-filter' : undefined} onClick={event => open(dimension, event.currentTarget)}>
{selected.length > 0 && <><Filter size={11} /><span className="analysis-inline-filter">{selected.length === 1 ? filterLabel : `${selected.length}项`}</span></>}<ChevronDown size={12} />
</button>
</div>;
})}
</div>
{activeCount > 0 && <div className="analysis-active-note"><span>{activeCount} 个维度已筛选 · 同维度多选为“或”,不同维度为“且”</span><button className="analysis-text-button" onClick={() => { setFilters({}); setEditor(null); }}>清空全部筛选</button></div>}
{editor && <div id="analysis-column-filter" className="analysis-filter-editor analysis-column-popover" style={position} role="group" aria-label={`${DIMENSION_LABELS[editor]}筛选菜单`} onKeyDown={event => { if (event.key === 'Escape') { event.stopPropagation(); closeEditor(); } }}>
<div className="analysis-filter-editor-top"><strong>{DIMENSION_LABELS[editor]}</strong><span>搜索与筛选</span><button className="analysis-icon-button" aria-label="关闭筛选编辑" onClick={closeEditor}><X size={15} /></button></div>
<label className="analysis-filter-search"><Search size={13} /><input autoFocus aria-label="搜索筛选值" placeholder={`搜索${DIMENSION_LABELS[editor]}`} value={query} onChange={event => setQuery(event.target.value)} /></label>
<div className="analysis-filter-selection"><button onClick={() => setDraft([...new Set([...draft, ...options.map(option => option.value)])])}>全选{query ? '搜索结果' : ''}</button><button onClick={() => setDraft([])}>清除选择</button><span>已选 {draft.length} 项</span></div>
<div className="analysis-filter-options" role="group" aria-label={`${DIMENSION_LABELS[editor]}选项`}>
{options.map(option => <label key={option.value}><input type="checkbox" checked={draft.includes(option.value)} onChange={event => setDraft(event.target.checked ? [...draft,option.value] : draft.filter(value => value !== option.value))} /><span>{option.label}</span><small>{option.count} 台</small></label>)}
{!options.length && <p>没有匹配选项,可调整搜索或其他筛选。</p>}
</div>
<div className="analysis-filter-editor-bottom"><span>未选择时不限制此维度</span><button className="analysis-button" onClick={closeEditor}>取消</button><button className="analysis-primary" onClick={apply}><Check size={13} />应用</button></div>
</div>}
</section>;
}
@@ -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 <AnalysisDialog key={JSON.stringify([selection, subject])} selection={selection} subject={subject} close={close} />;
}
function AnalysisDialog({ selection, subject, close }: Props & { selection: VehicleModalSelection }) {
const dialog = useRef<HTMLDivElement>(null);
const scrollRoot = useRef<HTMLDivElement>(null);
useDialogFocus(true, dialog, close);
const [data, setData] = useState<AssetAnalysisResponse | null>(null);
const [weekly, setWeekly] = useState<WeeklyDetailItem[]>([]);
const [busy, setBusy] = useState(true);
const [error, setError] = useState('');
const [retry, setRetry] = useState(0);
const view = initialView(selection);
const [dimensions, setDimensions] = useState<AssetAnalysisDimension[]>(() => getDefaultDimensions(view));
const [filterValues, setFilterValues] = useState<AnalysisFilterValues>({});
const [sort, setSort] = useState<AnalysisSort>('count-desc');
const [mergeParents, setMergeParents] = useState(true);
const [detailGroup, setDetailGroup] = useState<CustomAnalysisRow | null>(null);
const showDetails = detailGroup !== null;
const [vehicles, setVehicles] = useState<VehicleListItem[]>([]);
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<AssetAnalysisResponse>(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<VehicleListItem[]>(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 <div className="asset-analysis-overlay">
<div ref={dialog} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} className={`asset-analysis-dialog ${view === 'abnormal' ? 'analysis-amber' : ''}`}>
<header className="analysis-header"><div><h2><BarChart3 size={16} />{title}</h2><p>{scopeLabel}</p></div><button type="button" aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>
{busy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在汇总统计数据…</div> : error ? <div className="analysis-message" role="alert"><p>统计数据加载失败:{error}</p><button className="analysis-button" onClick={() => setRetry(retry + 1)}>重新加载</button></div> : request.kind === 'weekly' ? <WeeklyAnalysis rows={weekly} /> : <>
{showDetails && <div className="analysis-navigation"><button className="analysis-back" onClick={() => setDetailGroup(null)}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup.label}</span></div>}
<div ref={scrollRoot} className="analysis-scroll">
{!showDetails && <>
<AnalysisControls view={view} cells={data?.cells ?? []} available={available} dimensions={dimensions} setDimensions={updateDimensions} filters={filterValues} setFilters={updateFilters} reset={reset} />
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(analysis.total)}</strong> 台</span><span>{analysis.rows.length} 个分组</span><span className="analysis-result-dimensions">{analysis.dimensions.length ? analysis.dimensions.map(dimension => DIMENSION_LABELS[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
{view === 'inventory' && <div className="analysis-inventory-subsets" aria-label="库存子集统计"><span className="analysis-pending-count">待交车 <strong>{number(analysis.summary.pending)}</strong> 台</span><span className="analysis-abnormal-count">异动 <strong>{number(analysis.summary.abnormal)}</strong> 台</span><small>已包含在当前库存数量中</small></div>}
{view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
<div className="analysis-table-tools"><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options">{analysis.dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}<label>排序<select aria-label="统计分组排序" value={sort} onChange={event => { setSort(event.target.value as AnalysisSort); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">维度顺序</option></select></label></div></div>
{analysis.total === 0 ? <div className="analysis-message"><BarChart3 size={22} /><h3>没有符合条件的数据</h3><p>移除部分筛选条件,或恢复当前入口的全部数据。</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
<div className={`analysis-group-table${merged ? ' is-merged' : ''}`} role="region" aria-label="组合统计表" tabIndex={0}><table>
<thead><tr>{analysis.dimensions.length ? analysis.dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(台)</th>{view === 'inventory' && <><th className="analysis-numeric">待交车</th><th className="analysis-numeric">异动</th></>}<th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead>
<tbody>{shownGroups.map((row, rowIndex) => <tr key={row.key} className={merged && spans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
{analysis.dimensions.length ? analysis.dimensions.map((dimension, columnIndex) => {
const span = merged ? spans[rowIndex][columnIndex] : 1;
return span === 0 ? null : <td key={dimension} rowSpan={span} className={span > 1 ? 'analysis-parent-cell' : undefined} data-label={DIMENSION_LABELS[dimension]}><Blur>{getDimensionValueLabel(dimension, row.values[dimension] || '')}</Blur>{merged && columnIndex < analysis.dimensions.length - 1 && childCounts[rowIndex][columnIndex] > 1 && <><small className="analysis-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{number(parentTotals[rowIndex][columnIndex])}</strong> 台</small><small className="analysis-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{DIMENSION_LABELS[analysis.dimensions[columnIndex + 1]]}:<strong>{number(childCounts[rowIndex][columnIndex])}</strong> {dimensionCountUnit(analysis.dimensions[columnIndex + 1])}</small></>}</td>;
}) : <td data-label="统计范围">当前筛选结果</td>}
<td className="analysis-numeric" data-label="数量(台)"><strong>{number(row.count)}</strong></td>
{view === 'inventory' && <><td className={`analysis-numeric ${row.totals.pending ? 'analysis-pending-count' : 'analysis-zero-count'}`} data-label="待交车(台)">{number(row.totals.pending)}</td><td className={`analysis-numeric ${row.totals.abnormal ? 'analysis-abnormal-count' : 'analysis-zero-count'}`} data-label="异动(台)">{number(row.totals.abnormal)}</td></>}
<td className="analysis-share-cell" data-label="占比"><span>{row.share.toFixed(1)}%</span><span className="analysis-bar-track" aria-hidden="true"><span style={{ width: `${row.share}%` }} /></span></td>
<td className="analysis-group-actions">{analysis.dimensions.length > 0 && <button aria-label={`筛选分组 ${row.label}`} onClick={() => narrowToGroup(row)}><Filter size={12} />筛选此组</button>}<button aria-label={`查看明细 ${row.label}`} onClick={() => setDetailGroup(row)}>查看明细</button></td>
</tr>)}</tbody>
</table></div>
<div className="analysis-table-total"><span>合计 {number(analysis.total)} 台{view === 'inventory' && ` · 待交车 ${number(analysis.summary.pending)} 台 · 异动 ${number(analysis.summary.abnormal)} 台`} · 占比基于当前筛选结果</span><span>100%</span></div>
<ProgressiveRowsStatus {...groupProgress} unit="组" />
</>}
</>}
{showDetails && <div className="analysis-details">
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><label className="analysis-search"><Search size={15} /><input aria-label="搜索车辆明细" placeholder="搜索车牌、VIN、型号、城市" value={search} onChange={event => { setSearch(event.target.value); }} /></label></div>
{detailBusy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在加载车辆明细…</div> : detailError ? <div className="analysis-message" role="alert"><p>{detailError}</p><button className="analysis-button" onClick={() => setDetailRetry(detailRetry + 1)}>重新加载明细</button></div> : <>
{vehicles.length !== detailGroup.count && <p className="analysis-definition">车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。</p>}
{(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? <AbnormalVehicleDetails vehicles={shownVehicles} /> : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? <PendingVehicleDetails vehicles={shownVehicles} /> : <VehicleRows vehicles={shownVehicles} />}
<ProgressiveRowsStatus {...detailProgress} unit="台" />
</>}
</div>}
</div>
<footer className="analysis-footer"><span>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</span><span>{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}</span></footer>
</>}
</div>
</div>;
}
function VehicleRows({ vehicles }: { vehicles: VehicleListItem[] }) {
if (!vehicles.length) return <div className="analysis-message">暂无匹配车辆</div>;
const stateLabel = (vehicle: VehicleListItem) => {
const operation = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record<string, string>)[vehicle.operationStatus || ''] || vehicle.operationStatus;
const business = ({ Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' } as Record<string, string>)[vehicle.status] || vehicle.status;
return [business, operation].filter(Boolean).join(' · ');
};
return <div className="analysis-detail-table" tabIndex={0} role="region" aria-label="车辆明细表"><table><thead><tr>{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', '客户 / 签约公司', '部门 / 业务负责人', '所属公司'].map(label => <th key={label}>{label}</th>)}</tr></thead><tbody>{vehicles.map(vehicle => <tr key={vehicle.id}><td><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></td><td>{vehicle.brandLabel || '—'} · {vehicle.type}<small>{vehicle.model}</small></td><td>{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}</td><td>{stateLabel(vehicle)}</td><td><Blur>{vehicle.customerName || '—'}</Blur><small><Blur>{vehicle.orgName || '—'}</Blur></small></td><td><Blur>{vehicle.departmentName || '—'}</Blur><small><Blur>{vehicle.customerManager || '—'}</Blur></small></td><td>{vehicle.subjectOrg || '—'}</td></tr>)}</tbody></table></div>;
}
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<HTMLDivElement>(null);
const labels: Record<Dimension, string> = { date: '交还日期', contract_type: '合同类型' };
const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
const [mergeParents, setMergeParents] = useState(true);
const mergeActive = mergeParents && dimensions.length > 1;
const [filters, setFilters] = useState<Partial<Record<Dimension, string[]>>>({});
const [detailKey, setDetailKey] = useState<string | null>(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<string, { key: string; values: string[]; rows: WeeklyDetailItem[] }>();
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<Record<Dimension, string[]>>) => { 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<string, number>();
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 <div ref={scrollRoot} className="analysis-scroll">
{!showDetails ? <>
<div className="analysis-builder">
<div className="analysis-builder-heading"><h3>分析维度</h3><span>点击名称分组,点击箭头筛选</span><button type="button" className="analysis-reset" onClick={() => { updateDimensions(['date']); updateFilters({}); setMergeParents(true); }}>恢复默认</button></div>
<div className="adp-row" role="group" aria-label="交还车统计维度">
{dimensionsAvailable.map(dimension => {
const options = filterOptions[dimension];
return <DimensionPicker key={dimension} label={labels[dimension]} grouped={dimensions.includes(dimension)} order={dimensions.indexOf(dimension) + 1} options={options} selectedValues={filters[dimension]} onToggleGroup={() => updateDimensions(dimensions.includes(dimension) ? dimensions.filter(item => item !== dimension) : [...dimensions, dimension])} onApplyFilter={selected => setFilter(dimension, selected)} />;
})}
<button type="button" className="analysis-button" aria-pressed={dimensions.length === 0} onClick={() => updateDimensions([])}>仅筛选</button>
{activeFilters.length > 0 && <button type="button" className="analysis-text-button" onClick={() => updateFilters({})}>清空筛选</button>}
</div>
</div>
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(filtered.length)}</strong> 车次</span><span>{groups.length} 个分组</span><span className="analysis-result-dimensions">{dimensions.length ? dimensions.map(dimension => labels[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
<div className="analysis-table-tools"><p>筛选分组可继续分析,查看明细可核对业务记录。</p>{dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}</div>
{groups.length ? <>
<div className={`analysis-group-table${mergeActive ? ' is-merged' : ''}`} role="region" aria-label="交还车组合统计表" tabIndex={0}><table><thead><tr>{dimensions.length ? dimensions.map(dimension => <th key={dimension}>{labels[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(车次)</th><th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead><tbody>
{shownGroups.map((group, rowIndex) => <tr key={group.key} className={mergeActive && groupSpans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
{dimensions.length ? dimensions.map((dimension, index) => {
const span = mergeActive ? groupSpans[rowIndex][index] : 1;
return span === 0 ? null : <td data-label={labels[dimension]} key={dimension} rowSpan={span > 1 ? span : undefined} className={span > 1 ? 'analysis-parent-cell' : undefined}>{label(dimension, group.values[index])}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <><small className="analysis-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{number(parentTotals[rowIndex][index])}</strong> 车次</small><small className="analysis-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{labels[dimensions[index + 1]]}:<strong>{number(childCounts[rowIndex][index])}</strong> {dimensionCountUnit(dimensions[index + 1])}</small></>}</td>;
}) : <td data-label="统计范围">当前筛选结果</td>}
<td className="analysis-numeric" data-label="数量(车次)"><strong>{number(group.rows.length)}</strong></td><td className="analysis-share-cell" data-label="占比"><span>{(group.rows.length / filtered.length * 100).toFixed(1)}%</span><span className="analysis-bar-track" aria-hidden="true"><span style={{ width: `${group.rows.length / filtered.length * 100}%` }} /></span></td>
<td className="analysis-group-actions">{dimensions.length > 0 && <button type="button" aria-label={`筛选分组 ${groupLabel(group)}`} onClick={() => updateFilters({ ...filters, ...Object.fromEntries(dimensions.map((dimension, index) => [dimension, [group.values[index]]])) })}><Filter size={12} />筛选此组</button>}<button type="button" aria-label={`查看明细 ${groupLabel(group)}`} onClick={() => { setDetailKey(group.key); }}>查看明细</button></td>
</tr>)}
</tbody></table></div>
<div className="analysis-table-total"><span>合计 {number(filtered.length)} 车次 · 占比基于当前筛选结果</span><span>100%</span></div>
<ProgressiveRowsStatus {...groupProgress} unit="组" />
</> : <div className="analysis-message"><p>没有符合条件的数据,请调整筛选。</p>{activeFilters.length > 0 && <button type="button" className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div>}
</> : <>
<div className="analysis-navigation"><button type="button" className="analysis-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup ? groupLabel(detailGroup) : '当前分组'}</span></div>
<div className="analysis-table-tools"><h3>交还车业务明细 <small>{number(detailRows.length)} 车次</small></h3></div>
{detailRows.length ? <><div className="analysis-detail-table" role="region" aria-label="交还车业务明细表" tabIndex={0}><table><thead><tr><th>车牌</th><th>日期</th><th>合同类型</th><th>客户</th></tr></thead><tbody>{shownDetails.map((row, index) => <tr key={`${row.truck_id}-${index}`}><td><Blur>{row.plate_number || '—'}</Blur></td><td>{row.handover_date || '—'}</td><td>{row.contract_type || '—'}</td><td><Blur>{row.customer_name || '—'}</Blur></td></tr>)}</tbody></table></div>
<ProgressiveRowsStatus {...detailProgress} unit="条" />
</> : <p className="analysis-message">当前分组暂无数据,请返回统计分析。</p>}
</>}
</div>;
}
@@ -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<string[]>([]);
const [query, setQuery] = useState('');
const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 420 });
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const search = useRef<HTMLInputElement>(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 <div ref={root} className={`analysis-dimension-picker${grouped ? ' adp-grouped' : ''}${selected.length ? ' adp-filtered' : ''}`}>
<div className="adp-split">
<button type="button" className="adp-name" aria-pressed={grouped} onClick={() => { setOpen(false); onToggleGroup(); }} title={`点击${grouped ? '取消' : '加入'}${label}分组`}>
{grouped && order !== undefined ? <span className="adp-order">{order}</span> : null}{label}
</button>
<button ref={trigger} type="button" className="adp-arrow" aria-label={`筛选${label}`} aria-expanded={open} aria-controls={open ? menuId : undefined} aria-haspopup="dialog" onClick={show} title={selected.length ? `${label}已筛选 ${selected.length} 项:${selectedLabel}` : `筛选${label}`}>
{selected.length ? <><Filter size={11} /><span>{selected.length}</span></> : null}<ChevronDown size={12} />
</button>
</div>
{selected.length ? <button type="button" className="adp-selected" onClick={show} title={selectedLabel}>{selected.length === 1 ? selectedLabel : `${selectedLabel}(${selected.length} 项)`}</button> : null}
{open ? <div id={menuId} role="dialog" aria-label={`${label}筛选菜单`} className="adp-menu" style={{ left: position.left, top: position.top, maxHeight: position.maxHeight }} onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); } }}>
<div className="adp-menu-heading"><strong>{label}</strong><button type="button" aria-label={`关闭${label}筛选`} onClick={() => close()}><X size={14} /></button></div>
<label className="adp-search"><Search size={13} /><input ref={search} aria-label={`搜索${label}筛选值`} value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索选项" /></label>
<div className="adp-select-tools"><button type="button" disabled={!visible.length} onClick={() => setDraft(current => [...new Set([...current, ...visible.map(option => option.value)])])}>全选搜索结果</button><button type="button" onClick={() => setDraft([])}>清除选择</button><span>{draft.length} 项已选</span></div>
<div className="adp-options" role="group" aria-label={`${label}可选值`}>
{visible.map(option => <label key={option.value}><input type="checkbox" checked={draft.includes(option.value)} onChange={event => setDraft(current => event.target.checked ? [...current, option.value] : current.filter(value => value !== option.value))} /><span>{option.label}</span>{option.count !== undefined ? <small>{option.count.toLocaleString('zh-CN')}</small> : null}</label>)}
{!visible.length ? <p>没有匹配选项</p> : null}
</div>
<div className="adp-menu-footer"><span>同维度多选为“或”;未选表示不限</span><div><button type="button" onClick={() => close()}>取消</button><button type="button" className="adp-apply" onClick={() => { onApplyFilter(draft.length ? draft : undefined); close(); }}>应用筛选</button></div></div>
</div> : null}
</div>;
}
@@ -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<HTMLDivElement>;
unit?: string;
}
export function ProgressiveRowsStatus({ visibleCount, total, hasMore, loadMore, sentinelRef, unit = '条' }: Props) {
return <div ref={sentinelRef} className="analysis-progressive-status">
<span role="status" aria-live="polite">已加载 {visibleCount.toLocaleString('zh-CN')} / {total.toLocaleString('zh-CN')} {unit}</span>
{hasMore ? <><span className="analysis-progressive-hint">下滚继续加载</span><button type="button" onClick={loadMore}>加载更多</button></> : <span className="analysis-progressive-hint">已显示全部</span>}
</div>;
}
@@ -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); }
@@ -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; }
@@ -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; }
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useMemo, useState, type RefObject } from 'react';
interface ProgressiveRowsOptions {
total: number;
rootRef: RefObject<HTMLElement | null>;
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<HTMLDivElement | null>(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 };
}
@@ -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); }
@@ -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<string>;
toggle: (name: string) => void;
total: number;
filters: InventoryFilters;
select: Dispatch<SetStateAction<VehicleModalSelection | null>>;
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 (
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
<th className="p-3 font-semibold text-center w-32">库存数量</th>
</tr>
</thead>
<tbody className="text-xs">
{inventoryTab === 'region' ? (
Object.entries(inventoryByRegion).map(([region, cities]) => (
<React.Fragment key={region}>
<tr
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => toggleInventoryRegion(region)}
>
<td colSpan={4} className="p-3 font-bold text-slate-700">
<div className="flex flex-wrap items-center gap-2">
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span>{region}</span>
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection(Object.values(cities).flat(), `库存统计 - ${region}`));
}}>
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(cities).flat()} />
</div>
</td>
</tr>
<AnimatePresence>
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
<React.Fragment key={city}>
{stats.map((stat, idx) => (
<motion.tr
key={`${city}-${stat.model}-${idx}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
>
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
<td className="p-3 text-center font-bold text-blue-600">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`));
}}
className="text-blue-600 hover:underline font-bold"
>
{stat.quantity}
</button>
{((stat.pending ?? 0) > 0 || (stat.abnormal ?? 0) > 0) && (
<div className="mt-1"><InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} /></div>
)}
</td>
</motion.tr>
))}
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))
) : (
Object.entries(inventoryByModel).map(([type, models]) => (
<React.Fragment key={type}>
<tr
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => toggleInventoryType(type)}
>
<td colSpan={4} className="p-3 font-bold text-slate-700">
<div className="flex flex-wrap items-center gap-2">
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span>{type}</span>
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection(Object.values(models).flat(), `库存统计 - ${type}`));
}}>
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(models).flat()} />
</div>
</td>
</tr>
<AnimatePresence>
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
<React.Fragment key={model}>
{stats.map((stat, idx) => (
<motion.tr
key={`${model}-${stat.region}-${stat.city}-${idx}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
>
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
<td className="p-3 text-center font-bold text-blue-600">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`));
}}
className="text-blue-600 hover:underline font-bold"
>
{stat.quantity}
</button>
{((stat.pending ?? 0) > 0 || (stat.abnormal ?? 0) > 0) && (
<div className="mt-1"><InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} /></div>
)}
</td>
</motion.tr>
))}
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))
)}
</tbody>
</table>
</div>
);
}
export function InventoryQuantity({ quantity, label, onClick }: { quantity: number; label: string; onClick: () => void }) {
return <button className="inventory-quantity" disabled={!quantity} onClick={onClick} aria-label={`${label},库存 ${quantity} 台,查看车辆明细`}>
{quantity.toLocaleString()}<ArrowUpRight size={14} aria-hidden="true" />
</button>;
}
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<InventoryListProps, 'tab' | 'filters' | 'select'>) {
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 <div className="inventory-share"><span className="inventory-share-track" aria-hidden="true"><span style={{ width: `${percent}%` }} /></span><span>{percent.toFixed(1)}%</span></div>;
}
function InventorySubtotal({ rows, label, select }: { rows: RegionalInventoryStats[]; label: string; select: InventoryListProps['select'] }) {
return <div className={`inventory-subtotal${rows.length === 1 ? ' inventory-subtotal-inline' : ''}`} aria-label={`${label}合计 ${inventoryTotal(rows)} 台`}>
<button className="inventory-subtotal-button" disabled={!inventoryTotal(rows)} aria-label={`${label}合计 ${inventoryTotal(rows)} 台,查看车辆明细`} onClick={() => select(inventorySubtotalSelection(rows, label))}>合计 <strong>{inventoryTotal(rows).toLocaleString()}</strong> 台<ArrowUpRight size={12} aria-hidden="true" /></button>
<InventoryStatusCounts rows={rows} />
</div>;
}
function InventoryDetailRows({ rows, city, tab, total, select }: Pick<InventoryListProps, 'tab' | 'total' | 'select'> & { 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) =>
<tr className="inventory-detail-row" key={`${brandIndex}-${variantIndex}-${index}`}>
{tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && <th rowSpan={rows.length} scope="rowgroup" className="inventory-merged-cell inventory-city-cell">{city || '未分配'}<InventorySubtotal select={select} rows={rows} label={city || '未分配'} /></th>}
{variantIndex === 0 && index === 0 && <td rowSpan={brand.rows.length} className="inventory-merged-cell">{brand.brand || '—'}<InventorySubtotal select={select} rows={brand.rows} label={brand.brand || '未分配品牌'} /></td>}
{index === 0 && <td rowSpan={variants.length} className="inventory-merged-cell">
{tab === 'region' ? <span className="inventory-tag inventory-tag-type">{inventoryTypeLabel(row.type) || '未分类'}</span> : <InventoryModelTags row={row} hideType />}
<InventorySubtotal select={select} rows={variants} label={tab === 'region' ? inventoryTypeLabel(row.type) : row.model} />
</td>}
{tab === 'model' && regionSpan > 0 && <td rowSpan={regionSpan} className="inventory-merged-cell inventory-region-cell">{row.region || '未分配'}<InventorySubtotal select={select} rows={variants.filter(item => item.region === row.region)} label={row.region || '未分配'} /></td>}
<td>{tab === 'region' ? <InventoryModelTags row={row} hideType /> : row.city || '未分配'}</td>
<td className="inventory-number-column"><InventoryQuantity quantity={row.quantity} label={`${row.city} · ${row.brand} · ${row.model}`} onClick={() => selectInventoryRow(row, select)} /><InventoryStatusCounts rows={[row]} /></td>
<td><InventoryShare quantity={row.quantity} total={total} /></td>
</tr>
)));
}
export function InventoryDesktopTable(props: InventoryListProps) {
const { groups, tab, expanded, toggle, total, select } = props;
return <div className="inventory-desktop">
<table className="inventory-table" data-group-by={tab}>
<caption className="sr-only">按{tab === 'region' ? '区域' : '车型'}分组的库存统计,占比基于当前筛选库存总数</caption>
<thead><tr>
{tab === 'region' && <th scope="col">区域 / 城市</th>}
<th scope="col">品牌</th>
{tab === 'region' && <th scope="col">车型</th>}
<th scope="col">类别</th>
{tab === 'model' && <><th scope="col">区域</th><th scope="col">城市</th></>}
<th scope="col" className="inventory-number-column">库存数量</th>
<th scope="col" className="inventory-share-column">库存占比</th>
</tr></thead>
{groups.map(group => <Fragment key={group.name}><tbody>
<tr className="inventory-group-row" data-expanded={expanded.has(group.name)}>
<th scope="row" colSpan={4}>
<div className="inventory-group-heading">
<button className="inventory-expand" aria-expanded={expanded.has(group.name)} onClick={() => toggle(group.name)}>
<ChevronRight size={16} aria-hidden="true" /><strong>{group.name || '未分配'}</strong><span>{group.sections.length} 个{tab === 'region' ? '城市' : '型号'}</span>
</button>
<InventoryStatusCounts rows={group.rows} />
</div>
</th>
<td className="inventory-number-column"><InventoryQuantity quantity={group.quantity} label={group.name} onClick={() => selectInventoryGroup(group, props)} /></td>
<td><InventoryShare quantity={group.quantity} total={total} /></td>
</tr>
</tbody>
{expanded.has(group.name) && (tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) => <tbody key={name} className="inventory-detail-group">
<InventoryDetailRows rows={rows} city={name} tab={tab} total={total} select={select} />
</tbody>)}
</Fragment>)}
</table>
</div>;
}
@@ -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<InventoryListProps, 'tab' | 'select'> & { group: InventoryDisplayGroup }) {
const [path, setPath] = useState<string[]>([]);
const [requestedPage, setPage] = useState(0);
const heading = useRef<HTMLHeadingElement>(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 <div className="inventory-mobile-details">
{view.parents.length > 0 && <nav className="inventory-mobile-breadcrumb" aria-label="库存下钻路径">
<button onClick={() => navigate([])}>{group.name || '未分配'}</button>
{view.parents.map((parent, index) => <Fragment key={parent.key}>
<ChevronRight size={12} aria-hidden="true" />
{index === view.parents.length - 1 ? <span aria-current="location">{parent.label}</span> :
<button onClick={() => navigate(activePath.slice(0, index + 1))}>{parent.label}</button>}
</Fragment>)}
</nav>}
<div className="inventory-mobile-detail-toolbar">
<h3 ref={heading} tabIndex={-1}>{labels[view.dimension]}库存 <span>· {view.groups.length} 项</span></h3>
{view.parents.length > 0 && <button className="inventory-text-button" onClick={() => navigate(activePath.slice(0, -1))}><ChevronLeft size={14} aria-hidden="true" />返回上一级</button>}
export function InventoryMobileList({
inventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
}: InventoryMobileListProps) {
return (
<div className="lg:hidden p-3 space-y-3">
{inventoryTab === 'region' ? (
Object.entries(inventoryByRegion).map(([region, cities]) => (
<div key={region} className="border border-slate-100 rounded overflow-hidden">
<div
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
onClick={() => toggleInventoryRegion(region)}
>
<div className="flex items-center gap-2">
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span className="text-xs font-bold text-slate-700">{region}</span>
</div>
<div className="inventory-mobile-list-heading"><span>{labels[view.dimension]}</span><span>库存(台)</span></div>
<ul className="inventory-mobile-records" aria-label={`${labels[view.dimension]}库存列表`}>
{pageGroups.map(item => <li key={item.key} className="inventory-mobile-drill-row">
<button className="inventory-mobile-drill-label"
aria-label={view.dimension === 'variant' ? `${item.label},查看车辆明细` : `查看${item.label}的${view.dimension === 'region' ? '城市' : view.dimension === 'city' && tab === 'region' ? '车型' : '品牌型号'}库存`}
onClick={() => view.dimension === 'variant' ? openVehicles(item.rows, item.label) : navigate([...activePath, item.key])}>
<span>{item.label}</span>
{view.dimension !== 'variant' && <ChevronRight size={14} aria-hidden="true" />}
<div className="flex flex-col items-end gap-1 text-right">
<span className="text-[10px] font-bold text-blue-600">
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(cities).flat()} />
</div>
</div>
{expandedInventoryRegions.has(region) && (
<div className="p-2 space-y-2 bg-white">
{Object.entries(cities).map(([city, stats]) => (
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
<div className="space-y-2">
{stats.map((stat, idx) => (
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col">
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
<span className="text-slate-700 font-medium">{stat.model}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<button
onClick={() => setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`))}
className="font-bold text-blue-600 hover:underline"
>
{stat.quantity} 台
</button>
<InventoryQuantity quantity={item.quantity} label={item.label} onClick={() => openVehicles(item.rows, item.label)} />
{item.rows.some(row => row.abnormal || row.pending) && <div className="inventory-mobile-status"><InventoryStatusCounts rows={item.rows} /></div>}
</li>)}
</ul>
<div className="inventory-mobile-scope-total" role="status"><span>当前范围合计</span><strong>{inventoryTotal(view.rows).toLocaleString()} 台</strong></div>
{pageCount > 1 && <nav className="inventory-mobile-pagination" aria-label={`${group.name}明细分页`}>
<button disabled={page === 0} onClick={() => setPage(page - 1)}><ChevronLeft size={16} aria-hidden="true" />上一页</button>
<span aria-live="polite">{start + 1}–{start + pageGroups.length} / {view.groups.length}</span>
<button disabled={page === pageCount - 1} onClick={() => setPage(page + 1)}>下一页<ChevronRight size={16} aria-hidden="true" /></button>
</nav>}
</div>;
}
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} />
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
))
) : (
Object.entries(inventoryByModel).map(([type, models]) => (
<div key={type} className="border border-slate-100 rounded overflow-hidden">
<div
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
onClick={() => toggleInventoryType(type)}
>
<div className="flex items-center gap-2">
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span className="text-xs font-bold text-slate-700">{type}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<span className="text-[10px] font-bold text-blue-600">
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(models).flat()} />
</div>
</div>
export function InventoryMobileList(props: InventoryListProps) {
const { groups, tab, total } = props;
const [activeGroup, setActiveGroup] = useState<string | null>(null);
const id = useId();
return <div className="inventory-mobile">
<p className="inventory-mobile-hint">点击名称逐层查看,点击数量查看车辆</p>
{groups.map((group, index) => <article key={group.name} className="inventory-mobile-group" data-expanded={activeGroup === group.name}>
<div className="inventory-mobile-group-heading">
<button className="inventory-expand" aria-expanded={activeGroup === group.name} aria-controls={`${id}-${index}`} onClick={() => setActiveGroup(activeGroup === group.name ? null : group.name)}>
<span className="inventory-mobile-group-icon">{tab === 'region' ? <MapPin size={16} aria-hidden="true" /> : <Truck size={16} aria-hidden="true" />}</span><strong>{group.name || '未分配'}</strong><span>{inventoryMobileGroups(group.rows, tab === 'region' ? 'city' : 'region').length} 个{tab === 'region' ? '城市' : '区域'}</span><ChevronRight size={14} aria-hidden="true" />
{expandedInventoryTypes.has(type) && (
<div className="p-2 space-y-2 bg-white">
{Object.entries(models).map(([model, stats]) => (
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
<div className="space-y-2">
{stats.map((stat, idx) => (
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col">
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<button
onClick={() => setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`))}
className="font-bold text-blue-600 hover:underline"
>
{stat.quantity} 台
</button>
<InventoryQuantity quantity={group.quantity} label={group.name} onClick={() => selectInventoryGroup(group, props)} />
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} />
</div>
<div className="inventory-mobile-group-meta"><span>库存占比</span><InventoryShare quantity={group.quantity} total={total} /></div>
{(group.rows.some(row => row.abnormal || row.pending)) && <div className="inventory-mobile-status"><InventoryStatusCounts rows={group.rows} /></div>}
<div id={`${id}-${index}`} hidden={activeGroup !== group.name}>
{activeGroup === group.name && <MobileGroupDetails group={group} tab={tab} select={props.select} />}
</div>
</article>)}
</div>;
))}
</div>
</div>
))}
</div>
)}
</div>
))
)}
</div>
);
}
@@ -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<keyof InventoryFilters, string> = { 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<HTMLButtonElement>(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 (
<section data-testid="inventory-statistics" className="inventory-statistics" aria-labelledby={`${id}-heading`}>
<header className="inventory-heading">
<div className="inventory-title">
<span className="inventory-title-icon"><Boxes size={20} aria-hidden="true" /></span>
<div><h2 id={`${id}-heading`}>库存统计</h2><p>按区域、车型核对库存分布</p></div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
<div className="flex items-center gap-3">
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
<div className="flex flex-col">
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
<span className="text-[10px] text-gray-400 font-medium">{preview ? '公共库存汇总 · 明细按当前用户权限查看' : '实时更新库存数据'}</span>
</div>
<button ref={filterTrigger} className="inventory-control" aria-expanded={filterOpen} aria-controls={`${id}-filters`}
onClick={() => { if (!filterOpen) setDraft({ ...filters }); setFilterOpen(!filterOpen); }}>
<Filter size={16} aria-hidden="true" /> 筛选{activeFilters.length > 0 && <span className="inventory-filter-count">{activeFilters.length}</span>}
<ChevronDown size={14} aria-hidden="true" />
</button>
</header>
<div className="inventory-metrics" data-testid="inventory-status-summary">
<button className="inventory-metric inventory-metric-primary" disabled={!total}
onClick={() => select(inventorySelection(filters, activeFilters.length ? '库存统计 · 筛选结果' : '库存统计 · 全部库存'))}>
<span>{activeFilters.length ? '筛选后库存' : '库存总数'}<ArrowUpRight size={16} aria-hidden="true" /></span>
<strong>{total.toLocaleString()}<small>台</small></strong>
</button>
<button className="inventory-metric inventory-metric-pending" disabled={!pending}
onClick={() => select(inventorySelection(filters, '库存统计 · 待交车', { category: 'Pending' }))}>
<span>其中待交车<ArrowUpRight size={16} aria-hidden="true" /></span>
<strong>{pending.toLocaleString()}<small>台</small></strong>
</button>
<button className="inventory-metric inventory-metric-warning" disabled={!abnormal}
onClick={() => select(inventorySelection(filters, '库存统计 · 异动', { category: 'Abnormal' }))}>
<span>其中异动<ArrowUpRight size={16} aria-hidden="true" /></span>
<strong>{abnormal.toLocaleString()}<small>台</small></strong>
</button>
</div>
<div className="inventory-summary-note"><span>{new Set(rows.map(row => row.region)).size} 个区域 · {new Set(rows.map(row => row.city)).size} 个城市</span><span>待交车、异动已包含在库存总数中</span></div>
{filterOpen && <form id={`${id}-filters`} className="inventory-filters" aria-label="库存筛选"
onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); closeFilters(); } }}
onSubmit={event => { event.preventDefault(); setFilters({ ...draft }); closeFilters(); }}>
<div className="inventory-filter-heading"><strong>筛选库存</strong><button type="button" className="inventory-icon-button" aria-label="关闭库存筛选" onClick={closeFilters}><X size={18} aria-hidden="true" /></button></div>
<div className="inventory-filter-fields">
{(Object.keys(filterLabels) as (keyof InventoryFilters)[]).map(key => <label key={key}>
{filterLabels[key]}
<select value={draft[key]} onChange={event => setDraft({ ...draft, [key]: event.target.value, ...(key === 'type' ? { model: '' } : {}), ...(key === 'region' ? { city: '' } : {}) })}>
<option value="">全部{filterLabels[key]}</option>
{options[key].filter(Boolean).map(value => <option key={value} value={value}>{value}</option>)}
<div className="flex items-center gap-6 ml-auto pr-2">
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowPlateNumbers(inventorySubtotalSelection(filteredInventoryStats, '库存总数'))}>
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
<span className="text-2xl font-black text-gray-900 leading-none">
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
</span>
</div>
<div className="relative">
<button
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
? 'bg-blue-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<Filter size={14} />
<span>筛选</span>
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
)}
</button>
<AnimatePresence>
{isInventoryFilterOpen && (
<>
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3>
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button>
</div>
<div className="space-y-3 text-left">
<div>
<label className="text-[10px] text-slate-400 block mb-1">区域</label>
<select value={draftInventoryFilters.region} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, region: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部区域</option>
{uniqueInventoryRegions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</label>)}
</div>
<div className="inventory-filter-actions">
<button type="button" className="inventory-text-button" onClick={() => setDraft({ ...emptyFilters })}>重置条件</button>
<button type="button" className="inventory-control" onClick={closeFilters}>取消</button>
<button type="submit" className="inventory-control inventory-control-primary">应用筛选</button>
<div>
<label className="text-[10px] text-slate-400 block mb-1">城市</label>
<select value={draftInventoryFilters.city} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, city: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部城市</option>
{uniqueInventoryCities.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">品牌</label>
<select value={draftInventoryFilters.brand} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, brand: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部品牌</option>
{uniqueInventoryBrands.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">车型</label>
<select value={draftInventoryFilters.type} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, type: e.target.value, model: ''})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部车型</option>
{uniqueInventoryTypes.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">批次</label>
<select value={draftInventoryFilters.model} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部批次</option>
{uniqueInventoryModelsForType.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
<button onClick={() => { setInventoryFilters({...draftInventoryFilters}); setIsInventoryFilterOpen(false); }} className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors">确认</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
</div>
</div>
</form>}
{activeFilters.length > 0 && <div className="inventory-active-filters" aria-label="已应用的库存筛选">
<span>已筛选</span>
{activeFilters.map(key => <button key={key} onClick={() => setFilters({ ...filters, [key]: '', ...(key === 'type' ? { model: '' } : {}), ...(key === 'region' ? { city: '' } : {}) })}
aria-label={`移除${filterLabels[key]}筛选:${filters[key]}`}>
{filterLabels[key]}:{filters[key]}<X size={13} aria-hidden="true" />
</button>)}
<button className="inventory-clear" onClick={resetFilters}>清除全部</button>
</div>}
<div className="inventory-toolbar">
<div className="inventory-view-switch" role="group" aria-label="库存分组方式">
<button aria-pressed={tab === 'region'} onClick={() => setTab('region')}><MapPin size={15} aria-hidden="true" />按区域</button>
<button aria-pressed={tab === 'model'} onClick={() => setTab('model')}><Truck size={15} aria-hidden="true" />按车型</button>
{filteredInventoryStats.some(row => (row.pending ?? 0) > 0 || (row.abnormal ?? 0) > 0) && (
<div className="border-b border-gray-100 px-4 py-2" data-testid="inventory-status-summary">
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={filteredInventoryStats} />
</div>
<div className="inventory-toolbar-actions">
<label className="inventory-sort"><span className="sr-only">库存分组排序</span><select value={sort} onChange={event => setSort(event.target.value as typeof sort)}><option value="default">默认排序</option><option value="quantity">库存从多到少</option></select></label>
<button className="inventory-text-button inventory-expand-all" disabled={!groups.length} onClick={() => groups.forEach(group => { if (expanded.has(group.name) === allExpanded) toggle(group.name); })}>
{allExpanded ? <ChevronsDownUp size={16} aria-hidden="true" /> : <ChevronsUpDown size={16} aria-hidden="true" />}{allExpanded ? '收起全部' : '展开全部'}
)}
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
<button
onClick={() => setInventoryTab('region')}
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
按区域
</button>
<button
onClick={() => setInventoryTab('model')}
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
按车型
</button>
</div>
</div>
{groups.length ? <><InventoryDesktopTable {...listProps} /><InventoryMobileList key={JSON.stringify([tab, filters])} {...listProps} /></> :
<div className="inventory-empty"><SearchX size={32} aria-hidden="true" /><h3>{activeFilters.length ? '没有符合条件的库存' : '暂无库存数据'}</h3><p>{activeFilters.length ? '试试减少筛选条件,或清除筛选查看全部库存。' : '当前所属公司范围内暂无库存记录。'}</p>{activeFilters.length > 0 && <button className="inventory-control" onClick={resetFilters}>清除筛选</button>}</div>}
<footer className="inventory-footer"><span role="status" aria-live="polite">共 {groups.length} 个{tab === 'region' ? '区域' : '车型'} · {rows.length} 条记录 · {total.toLocaleString()} 台库存</span><span>占比基于当前筛选结果 · 单位:台</span></footer>
</section>
{/* Active Filters Bar */}
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
{inventoryFilters.region && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
区域: {inventoryFilters.region}
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.city && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
城市: {inventoryFilters.city}
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.brand && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
品牌: {inventoryFilters.brand}
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.type && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
车型: {inventoryFilters.type}
<button onClick={() => setInventoryFilters({...inventoryFilters, type: '', model: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.model && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
批次: {inventoryFilters.model}
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
<button onClick={() => setInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
</div>
)}
{/* Desktop View Table */}
<InventoryDesktopTable {...listProps} />
{/* Mobile View */}
<InventoryMobileList {...listProps} />
</div>
);
}
@@ -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 <span className="inline-flex flex-wrap gap-x-3 gap-y-1 text-[11px] font-normal" data-testid="inventory-status-counts">
{abnormal > 0 && <span className="inventory-status-abnormal text-amber-700">其中异动 <strong>{abnormal}</strong> 台</span>}
{pending > 0 && <span className="inventory-status-pending text-blue-700">其中待交车 <strong>{pending}</strong> 台</span>}
{abnormal > 0 && <button type="button" className="inventory-status-abnormal text-amber-700 hover:underline focus-visible:outline-2 focus-visible:outline-amber-500 rounded-sm" title="分析当前范围内的异动车辆" onClick={event => {
event.stopPropagation();
onSelect(inventorySubtotalSelection(rows, rows.length === 1 ? rows[0].model : '异动', 'Abnormal'));
}}>异动 <strong>{abnormal}</strong> 台</button>}
{pending > 0 && <span className="inventory-status-pending text-blue-700">待交车 <strong>{pending}</strong> 台</span>}
</span>;
}
@@ -52,11 +52,11 @@ export function SummaryMetrics({
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
<button type="button" data-testid="asset-abnormal-card" className="inline-flex items-center gap-1 text-amber-600 hover:underline"
onClick={(event) => { event.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Abnormal', source: 'asset', title: '库存中的异动车辆' }); }}>
<TriangleAlert size={12} />其中异动 <strong>{SUMMARY.inventory.abnormal}</strong>
<TriangleAlert size={12} />异动 <strong>{SUMMARY.inventory.abnormal}</strong>
</button>
<button type="button" className="inline-flex items-center gap-1 text-blue-600 hover:underline"
onClick={(event) => { event.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '库存中的待交车' }); }}>
<PlusCircle size={12} />其中待交车 <strong>{SUMMARY.pendingDelivery}</strong>
<PlusCircle size={12} />待交车 <strong>{SUMMARY.pendingDelivery}</strong>
</button>
</div>
@@ -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, /其中待交车 <strong>2<\/strong> 台/);
assert.match(html, /其中异动 <strong>1<\/strong> 台/);
assert.equal(renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [{ ...row, pending: 0, abnormal: 0 }] })), '');
const html = renderToStaticMarkup(createElement(InventoryStatusCounts, { onSelect: () => {}, rows: [row] }));
assert.match(html, /待交车 <strong>2<\/strong> 台/);
assert.match(html, /异动 <strong>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', () => {
@@ -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)} 台`,
};
}
@@ -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+']]);
});
+97
View File
@@ -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<T>(rows: readonly T[], dimensionCount: number, values: (row: T) => readonly string[]): number[][] {
const children = new Map<string, Set<string>>();
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<string>();
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<T>(
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<string, number>();
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<T>(
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<string, { rows: T[]; count: number }>();
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<T>(
rows: readonly T[],
dimensionCount: number,
values: (row: T) => readonly string[],
): number[][] {
const keys = rows.map(values);
const spans = rows.map(() => Array<number>(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)));
}
+2 -8
View File
@@ -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<UploadResult> {
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();
+3
View File
@@ -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);
+6 -10
View File
@@ -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) {
+17
View File
@@ -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]]);
});
+20
View File
@@ -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<string[]> {
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<any[]>(`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] : [])])];
}
+15 -12
View File
@@ -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();
}
+1 -1
View File
@@ -46,6 +46,6 @@ export function filterByPermission<T>(
// 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);
});
}
+61
View File
@@ -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<DirectoryUser | null>;
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<DirectoryUser[]> {
const db = identitySchema();
const [rows] = await pool.query<any[]>(`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<any[]>(`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<string>(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 };
},
};
+149
View File
@@ -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; }
}
});
@@ -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)));
});
+68
View File
@@ -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<typeof createPreviewService>;
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();
};
}
+99
View File
@@ -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<string>; audit?: (event: PreviewAudit) => Promise<void>; now?: () => number; managerIds?: (userId: string, loginName: string) => Promise<string[]> } = {}) {
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<string, Session>();
const clean = () => { for (const [token, s] of sessions) if (s.expires <= now()) sessions.delete(token); };
async function authorized(actor: AuthUser): Promise<DirectoryUser> {
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();
+4
View File
@@ -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;
+4 -2
View File
@@ -58,13 +58,15 @@ export const gapStore = {
[userId, fingerprint, demand, reason],
);
},
async list(userId: string | null): Promise<DemandGap[]> {
async list(userId: string | null, options: { readOnly?: boolean } = {}): Promise<DemandGap[]> {
if (!options.readOnly) {
await ensure();
await purge();
}
const [rows] = await pool.query<RowDataPacket[]>(
`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[];
@@ -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 {
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 {
if (!user.preview) {
await gapStore.record(user.userId, demand, "generation");
recorded = true;
}
} catch {
/* No raw error or query payload in logs. */
}
@@ -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");
+12 -4
View File
@@ -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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`SELECT vehicle_kind,
+10 -3
View File
@@ -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<RowDataPacket[]>(
@@ -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);
@@ -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)));
});
@@ -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<DailyMileageReport> {
const snapshot = await getDailyReportSnapshot(reportDate);
export async function getOrGenerateDailyMileageReport(reportDate: string, options: DailyReportReadOptions = {}): Promise<DailyMileageReport> {
const snapshot = await getDailyReportSnapshot(reportDate, options);
if (snapshot) return snapshot;
return generateDailyMileageReport(reportDate, 'PREVIEW', 'LIVE');
}
@@ -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<RowDataPacket[]>(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<DailyMileageReport | null> {
await ensureDailyReportTable();
const [rows] = await pool.query<RowDataPacket[]>(
export async function getDailyReportSnapshot(reportDate: string, options: DailyReportReadOptions = {}): Promise<DailyMileageReport | null> {
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<MileageReportHistoryResponse> {
await ensureDailyReportTable();
export async function listDailyReportHistory(limit = 90, options: DailyReportReadOptions = {}): Promise<MileageReportHistoryResponse> {
const safeLimit = Math.min(366, Math.max(1, limit));
const [rows] = await pool.query<RowDataPacket[]>(
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<MileageReportH
ORDER BY report_date DESC
LIMIT ?`,
[safeLimit],
options,
);
const items: MileageReportHistoryItem[] = rows.map(row => ({
reportDate: row.report_date,
+9 -4
View File
@@ -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);
}
+75 -9
View File
@@ -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<string> | 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<Vehicle[]> {
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<string, string> = { t4_5: '4.5T普货', t4_5c: '4.5T冷链', t18: '18T', t49: '49T', trailer: '挂车', other: '其他' };
@@ -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> = {}): 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<typeof count>[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<string, unknown>) => 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<string, unknown>) => {
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);
}
});
+181
View File
@@ -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<string, string>;
}
/**
* 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<ApprovalTimes> {
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<string, string>)[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<string, AssetAnalysisCell>();
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;
});
}
@@ -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',
+4
View File
@@ -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;
+71
View File
@@ -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<Record<AssetAnalysisDimension, string>>;
/** Values within a dimension are ORed; dimensions and the entry scope are ANDed. */
export type AssetAnalysisFilterValues = Partial<Record<AssetAnalysisDimension, string[]>>;
export type AbnormalAgeBucket = '0-3d' | '3-7d' | '7-15d' | '15-30d' | '30d+' | 'unknown';
export interface AssetAnalysisCell extends Record<AssetAnalysisDimension, string> {
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[];
}
+7
View File
@@ -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';
}