feat(assets): 优化移动端分析体验并记录 AI 提问日志

This commit is contained in:
shishengliang
2026-10-13 16:24:28 +08:00
parent 39ecde55d4
commit 4dec824285
28 changed files with 781 additions and 86 deletions
@@ -34,7 +34,9 @@ import { useAuth } from "../../../auth/useAuth";
import { ThinkingState } from "./ThinkingState";
import { CompoundResultView } from "./CompoundResultView";
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
import { useMobileLayout } from "../useMobileLayout";
import "./assistant.css";
import "./mobile-assistant.css";
type Turn = {
id: number;
@@ -55,24 +57,28 @@ const PROMPTS = [
{
icon: BarChart3,
title: "现在多少车在运营、多少车在库存?",
mobileTitle: "运营与库存",
text: BUSINESS_QUESTIONS[0],
detail: "总资产 · 运营 · 库存 · 运营率",
},
{
icon: Truck,
title: "库存集中在哪些车型和区域?",
mobileTitle: "库存分布",
text: BUSINESS_QUESTIONS[1],
detail: "库存分布 · 继续查看车辆",
},
{
icon: History,
title: "最近7天交车、还车分别多少?",
mobileTitle: "近 7 天交还车",
text: BUSINESS_QUESTIONS[2],
detail: "按日趋势 · 业务明细",
},
{
icon: ListIcon,
title: "有哪些车辆需要跟进?",
mobileTitle: "待跟进车辆",
text: BUSINESS_QUESTIONS[3],
detail: "异动详情 · 预计完成时间",
},
@@ -94,6 +100,8 @@ export default function AssetAssistant({
const dataScopeLabel = !user ? "正在确认数据范围"
: user.permissionLevel === "full" ? "全量数据"
: user.permissionLevel === "department" ? "部门数据" : "本人数据";
const mobile = useMobileLayout();
const [contextExpanded, setContextExpanded] = useState(false);
const page = useRef<HTMLElement>(null);
useLayoutEffect(() => {
const viewport = window.visualViewport;
@@ -557,7 +565,7 @@ export default function AssetAssistant({
<p.icon size={18} aria-hidden="true" />
<ArrowUpRight size={14} aria-hidden="true" />
</div>
<strong>{p.title}</strong>
<strong>{mobile ? p.mobileTitle : p.title}</strong>
<span>{p.detail}</span>
</button>
))}
@@ -745,7 +753,7 @@ export default function AssetAssistant({
)}
</div>
<div className="aq-composer-area">
{turns.length > 0 && (
{turns.length > 0 && (!mobile || (viewedTurn !== undefined && viewedTurn !== turns.at(-1)?.id)) && (
<button
className="aq-latest-answer"
onClick={() => revealTurn(turns[turns.length - 1].id)}
@@ -754,8 +762,8 @@ export default function AssetAssistant({
</button>
)}
{lastPlan && (
<div className="aq-context">
<span>正在分析</span>
<details className="aq-context-disclosure" open={!mobile || contextExpanded} onToggle={event => { if (mobile) setContextExpanded(event.currentTarget.open); }}><summary><span>追问范围:{lastPlan.kind === 'flow' ? '交还车' : CATEGORY_LABELS[lastPlan.category]}{Object.keys(lastPlan.filters).length ? ` · ${Object.entries(lastPlan.filters).map(([dimension,values]) => `${GROUP_LABELS[dimension as Dimension]} ${values.length === 1 ? values[0] : `${values.length}项`}`).join(' · ')}` : ''}</span><ChevronDown size={15} /></summary><div className="aq-context">
<span className="aq-context-heading">当前条件</span>
<span>
{lastPlan.kind === "flow"
? "交退换车"
@@ -847,7 +855,7 @@ export default function AssetAssistant({
<Plus size={12} />
换个话题
</button>
</div>
</div></details>
)}
{independent && (
<p className="aq-topic-notice">
@@ -6,6 +6,8 @@ import type {
} from "../../../shared/assets/assistant";
import { ResultView, type ResultViewProps } from "./ResultView";
import { GenerativeResultView } from "./GenerativeResultView";
import { useMobileLayout } from '../useMobileLayout';
import { mobileResultSummary } from './mobile-presentation';
import { copyResultText } from "./result-presentation";
export type CompoundResultViewProps = Omit<
@@ -34,6 +36,7 @@ export function CompoundResultView({
activate,
...props
}: CompoundResultViewProps) {
const mobile = useMobileLayout();
const [selection, setSelection] = useState<{
sectionId: string;
observedActiveId?: string;
@@ -72,7 +75,7 @@ export function CompoundResultView({
externallyActive ||
sections[0];
const selectedIndex = sections.indexOf(selected);
const stacked = result.layout === "stack";
const stacked = result.layout === "stack" && !mobile;
const renderSection = (section: typeof selected) => {
const child = section.result;
const correction = child?.resultId
@@ -120,11 +123,11 @@ export function CompoundResultView({
className="aq-compound-heading"
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
>
<div>
<div className="aq-compound-title">
<strong>{result.title || "综合分析"}</strong>
<span>{sections.length}项分析</span>
</div>
<p id={result.resultId ? `aq-answer-${result.resultId}` : undefined}>
<p className={mobile ? "aq-mobile-compound-summary" : undefined} id={result.resultId ? `aq-answer-${result.resultId}` : undefined}>
{result.summary}
</p>
</header>
@@ -219,7 +222,7 @@ export function CompoundResultView({
<span className="aq-section-preview">
<strong>{section.title}</strong>
<span>
{section.result?.summary || section.error || "本项暂无结果"}
{section.result ? (mobile ? mobileResultSummary(section.result) : section.result.summary) : section.error || "本项暂无结果"}
</span>
</span>
{chosen ? <Check size={14} /> : <ArrowUpRight size={14} />}
@@ -45,6 +45,10 @@ import {
type PresentationDatum,
} from "./generative-presentation";
import { useMobileLayout } from '../useMobileLayout';
import { mobileBlockGroups, mobileResultSummary } from './mobile-presentation';
import { ResultActions } from './ResultActions';
const COLORS = [
"#4e806a",
"#527da5",
@@ -69,6 +73,7 @@ function DataTable({
operate,
disabled,
}: DataBlockProps) {
const mobile = useMobileLayout();
const [page, setPage] = useState(0);
const data = presentationData(result, block.dataset);
const columns = presentationFields(block);
@@ -78,7 +83,16 @@ function DataTable({
);
return (
<>
<div className="aq-table-scroll">
{mobile ? <div className="aq-mobile-data-list" aria-label={block.title || '分析数据'}>{data.slice(currentPage * 8, currentPage * 8 + 8).map((row, index) => {
const action = presentationAction(interaction, row);
const vehicle = block.dataset === 'rows' && columns.some(column => column.field === 'plate');
const primary = vehicle ? columns.filter(column => ['plate', 'status', 'event', 'vehicleClass', 'type', 'city'].includes(column.field)) : columns.slice(0, 3);
const extra = columns.filter(column => !primary.includes(column));
return <article className="aq-mobile-data-row" key={String(row.id ?? row.name ?? row.date ?? row.label ?? index)}>
{vehicle ? <><div className="aq-mobile-record-heading"><strong>{presentationValue(row.plate, 'plate')}</strong><span>{primary.filter(column => ['status', 'event'].includes(column.field)).map(column => presentationValue(row[column.field], column.field)).join(' · ')}</span></div><p>{primary.filter(column => !['plate', 'status', 'event'].includes(column.field)).map(column => presentationValue(row[column.field], column.field)).join(' · ')}</p></> : <div className="aq-mobile-data-values">{primary.map(column => <div key={column.field}><span>{column.label}</span><strong>{presentationValue(row[column.field], column.field)}</strong></div>)}</div>}
{(extra.length > 0 || action) && <div className="aq-mobile-data-footer">{extra.length > 0 && <details><summary>更多字段</summary><dl>{extra.map(column => <div key={column.field}><dt>{column.label}</dt><dd>{presentationValue(row[column.field], column.field)}</dd></div>)}</dl></details>}{action && <button disabled={disabled} aria-label={`${datumName(row)} · ${interaction!.label}`} onClick={() => operate(action, `${datumName(row)} · ${interaction!.label}`)}>继续分析<ArrowUpRight size={15} /></button>}</div>}
</article>;
})}</div> : <div className="aq-table-scroll">
<table>
<caption className="aq-sr-only">{block.title || "分析数据"}</caption>
<thead>
@@ -132,7 +146,7 @@ function DataTable({
})}
</tbody>
</table>
</div>
</div>}
{data.length > 8 && (
<div className="aq-generated-pagination">
<span>
@@ -159,6 +173,7 @@ function DataTable({
);
}
function DataChart(props: DataBlockProps) {
const mobile = useMobileLayout();
const { block, result, interaction, operate, disabled } = props;
const [expanded, setExpanded] = useState(false);
const allData = presentationData(result, block.dataset);
@@ -186,6 +201,15 @@ function DataChart(props: DataBlockProps) {
};
if (!series.length) return <DataTable {...props} />;
const common = { data, margin: { top: 8, right: 16, bottom: 0, left: 0 } };
if (mobile && (block.chart === 'bar' || (block.chart === 'donut' && block.dataset === 'groups')) && series.length === 1 && allData.every(row => typeof row[series[0].field] === 'number' && Number(row[series[0].field]) >= 0)) {
const field = series[0].field;
const max = Math.max(1, ...allData.map(row => typeof row[field] === 'number' ? Math.abs(row[field] as number) : 0));
return <div className="aq-mobile-ranking" aria-label={block.title || '分组图表'}>{(expanded ? allData : allData.slice(0, 5)).map((row, index) => {
const action = presentationAction(interaction, row);
const content = <><span className="aq-mobile-ranking-line"><span>{datumName(row)}</span><strong>{presentationValue(row[field], field)}</strong>{action && <ArrowUpRight size={15} />}</span><span className="aq-mobile-ranking-track"><span style={{ width: `${Math.abs(Number(row[field]) || 0) / max * 100}%` }} /></span></>;
return action ? <button key={index} disabled={disabled} aria-label={`${datumName(row)} · ${interaction!.label}`} onClick={() => explore(row)}>{content}</button> : <div className="aq-mobile-ranking-item" key={index}>{content}</div>;
})}{allData.length > 5 && <button className="aq-generated-expand" onClick={() => setExpanded(!expanded)}>{expanded ? '收起分组' : `查看全部 ${allData.length} 项`}</button>}</div>;
}
return (
<>
<div
@@ -294,7 +318,7 @@ function DataChart(props: DataBlockProps) {
: `当前展示前5项 · 查看全部${allData.length}项`}
</button>
)}
<details className="aq-generated-data">
{!mobile && <details className="aq-generated-data">
<summary>查看图表数据</summary>
<DataTable
{...props}
@@ -309,7 +333,7 @@ function DataChart(props: DataBlockProps) {
],
}}
/>
</details>
</details>}
</>
);
}
@@ -319,6 +343,9 @@ function Block({
operate,
disabled,
}: Omit<DataBlockProps, "interaction">) {
const mobile = useMobileLayout();
const [expandedMetrics, setExpandedMetrics] = useState(false);
const metrics = presentationMetrics(result, block.metrics);
const interaction = result.presentation?.interactions.find(
(item) => item.id === block.interactionId,
);
@@ -345,8 +372,8 @@ function Block({
) : block.kind === "narrative" ? (
<p className="aq-generated-narrative">{block.text}</p>
) : block.kind === "metrics" ? (
<div className="aq-generated-metrics">
{presentationMetrics(result, block.metrics).map((metric) => (
<><div className="aq-generated-metrics">
{(mobile && !expandedMetrics ? metrics.slice(0, 4) : metrics).map((metric) => (
<div key={metric.label}>
<span>{metric.label}</span>
<strong>
@@ -355,10 +382,10 @@ function Block({
</strong>
</div>
))}
</div>
</div>{mobile && metrics.length > 4 && <button className="aq-generated-expand" onClick={() => setExpandedMetrics(!expandedMetrics)}>{expandedMetrics ? '收起指标' : `其余 ${metrics.length - 4} 项指标`}</button>}</>
) : (
<>
{interaction && (
{interaction && !mobile && (
<p className="aq-generated-interaction">
点击{block.dataset === "daily" ? "日期" : "数据项"} →{" "}
{interaction.label}
@@ -391,6 +418,12 @@ function Block({
);
}
function MobileDataBlock({ block, table, ...props }: Omit<DataBlockProps, 'interaction'> & { table?: PresentationBlock }) {
const [view, setView] = useState<'chart' | 'table'>('chart');
const tableBlock: PresentationBlock = table || { ...block, kind: 'table', columns: block.columns || [{ field: block.dataset === 'daily' ? 'date' : block.dataset === 'comparison' ? 'label' : 'name', label: block.dataset === 'daily' ? '日期' : '分组' }, ...presentationSeries(block)] };
return <section className="aq-mobile-visual"><div className="aq-mobile-visual-header"><h4>{block.title || '数据分析'}</h4><div aria-label="图表与明细"><button aria-pressed={view === 'chart'} onClick={() => setView('chart')}>图表</button><button aria-pressed={view === 'table'} onClick={() => setView('table')}>明细</button></div></div><Block {...props} block={{ ...(view === 'chart' ? block : tableBlock), title: undefined }} /></section>;
}
function GenerativeResultViewContent({
result,
operate,
@@ -403,6 +436,7 @@ function GenerativeResultViewContent({
correctionResultId,
showCorrection,
}: ResultViewProps) {
const mobile = useMobileLayout();
const [noticeByResult, setNoticeByResult] = useState<{
key: string;
text: string;
@@ -536,7 +570,7 @@ function GenerativeResultViewContent({
已纠正上一条回答:{result.correction.message}
</p>
)}
{presentation.title && (
{!mobile && presentation.title && (
<h3 className="aq-generated-title">{presentation.title}</h3>
)}
{result.outcome === "empty" && (
@@ -552,15 +586,11 @@ function GenerativeResultViewContent({
{result.outcome === "unsupported" && (
<p className="aq-generated-notice">本次需求暂不能完整生成。</p>
)}
{presentation.blocks.map((block) => (
<Block
key={block.id}
block={block}
result={result}
operate={operate}
disabled={disabled || !!correctionMessage}
/>
))}
{mobile ? <>
<p className="aq-answer aq-mobile-lead" id={result.resultId ? `aq-answer-${result.resultId}` : undefined}>{mobileResultSummary(result)}</p>
{mobileBlockGroups(presentation.blocks).filter(({ block }) => block.kind !== 'summary' && block.kind !== 'narrative').map(({ block, table }) => block.kind === 'chart' ? <MobileDataBlock key={block.id} block={block} table={table} result={result} operate={operate} disabled={disabled || !!correctionMessage} /> : <Block key={block.id} block={block} result={result} operate={operate} disabled={disabled || !!correctionMessage} />)}
<details className="aq-secondary-details"><summary>分析说明</summary><p>{result.summary}</p>{presentation.blocks.filter(block => (block.kind === 'summary' || block.kind === 'narrative') && block.text && block.text !== result.summary).map(block => <p key={block.id}>{block.text}</p>)}</details>
</> : presentation.blocks.map((block) => <Block key={block.id} block={block} result={result} operate={operate} disabled={disabled || !!correctionMessage} />)}
{!!result.unanswered?.length && (
<div className="aq-compound-unanswered">
<strong>尚未回答</strong>
@@ -611,7 +641,12 @@ function GenerativeResultViewContent({
{result.contextNote && <p>{result.contextNote}</p>}
<p>来源:{result.source}</p>
</details>
<div className="aq-answer-actions">
<ResultActions primary={<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{mobile ? (active ? "当前追问范围" : "继续追问") : (active ? "正在基于此结果追问" : "基于此结果追问")}
</button>}>
<button
disabled={disabled || saving}
onClick={() => void save("export")}
@@ -619,12 +654,7 @@ function GenerativeResultViewContent({
<ArrowDownToLine size={14} />
导出明细
</button>
<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{active ? "正在基于此结果追问" : "基于此结果追问"}
</button>
<button
disabled={saving || flagged}
onClick={() => void save("feedback")}
@@ -642,7 +672,7 @@ function GenerativeResultViewContent({
<Copy size={14} />
复制完整结果
</button>
</div>
</ResultActions>
{notice && (
<p className="aq-notice" role="status">
{notice}
@@ -0,0 +1,8 @@
import type { ReactNode } from 'react';
import { MoreHorizontal } from 'lucide-react';
import { useMobileLayout } from '../useMobileLayout';
export function ResultActions({ children, primary }: { children: ReactNode; primary?: ReactNode }) {
const mobile = useMobileLayout();
return mobile ? <div className="aq-mobile-actions">{primary}<details className="aq-more-actions"><summary><MoreHorizontal size={18} />更多</summary><div className="aq-answer-actions">{children}</div></details></div> : <div className="aq-answer-actions">{primary}{children}</div>;
}
+22 -13
View File
@@ -26,7 +26,12 @@ import {
compactAnalysisPath,
} from "./result-presentation";
import { useMobileLayout } from '../useMobileLayout';
import { mobileResultSummary } from './mobile-presentation';
import { ResultActions } from './ResultActions';
function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
const mobile = useMobileLayout();
const [page, setPage] = useState(0),
[search, setSearch] = useState("");
const filtered = rows.filter((r) =>
@@ -52,7 +57,7 @@ function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
</label>
<span>{filtered.length}条</span>
</div>
<div className="aq-table-scroll">
{mobile ? <div className="aq-mobile-data-list" aria-label="车辆明细">{filtered.slice(current * 8, current * 8 + 8).map(r => <article className="aq-mobile-data-row" key={r.id}><div className="aq-mobile-record-heading"><strong>{r.plate || '未登记车牌'}</strong><span>{flow ? r.event : r.status}</span></div><p>{r.type} · {r.city || '未标注城市'}</p><details><summary>更多资料</summary><dl>{[['型号', r.model], ['部门', r.department], ...(flow ? [['日期', r.date]] : [['库存子集', r.abnormal ? '异动' : r.pending ? '待交车' : '—'], ['异动类型', r.abnormalType], ['预计完成时间', r.targetCompletionTime]])].map(([label,value]) => <div key={label}><dt>{label}</dt><dd>{value || '—'}</dd></div>)}</dl></details></article>)}</div> : <div className="aq-table-scroll">
<table>
<thead>
<tr>
@@ -90,7 +95,7 @@ function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) {
))}
</tbody>
</table>
</div>
</div>}
{!filtered.length && <p className="aq-no-data">没有符合条件的明细</p>}
<div className="aq-pagination">
<span>
@@ -148,6 +153,7 @@ function ResultViewContent({
showCorrection,
display: preferredDisplay,
}: ResultViewProps) {
const mobile = useMobileLayout();
const [expandedGroupResult, setExpandedGroupResult] = useState<string | null>(
null,
),
@@ -360,7 +366,7 @@ function ResultViewContent({
className="aq-answer"
id={result.resultId ? `aq-answer-${result.resultId}` : undefined}
>
{result.summary}
{mobile ? mobileResultSummary(result) : result.summary}
</p>
{!result.conciseCount && !!visibleMetrics.length && (
<div className="aq-metrics aq-primary-metrics">
@@ -790,6 +796,8 @@ function ResultViewContent({
点击分组继续下钻 · 筛选条件自动用于下一次追问
</p>
</div>
) : mobile ? (
<div className="aq-mobile-data-list" aria-label="分组明细">{result.groups.map(g => <article className="aq-mobile-data-row" key={JSON.stringify(g.keys)}><div className="aq-mobile-data-values"><div><span>分组</span><strong>{g.name}</strong></div><div><span>车辆数</span><strong>{g.count}</strong></div><div><span>占比</span><strong>{g.share == null ? '—' : `${g.share}%`}</strong></div></div><div className="aq-mobile-data-footer">{plan.category !== 'operating' && <details><summary>更多指标</summary><p>运营率 {g.rate}% · 异动 {g.abnormal ?? '—'} · 待交车 {g.pending ?? '—'}</p></details>}<button disabled={disabled || !Object.keys(g.keys || {}).length} aria-label={`下钻${g.name}`} onClick={() => run({ type: 'drill', keys: g.keys || {} }, `下钻${g.name}`)}>继续分析<ArrowUpRight size={15} /></button></div></article>)}</div>
) : (
<div className="aq-table-scroll">
<table>
@@ -859,6 +867,7 @@ function ResultViewContent({
<summary>
统计口径、权限与数据时间{result.warning ? " · 有补充说明" : ""}
</summary>
{mobile && <p>{result.summary}</p>}
<p>
公司:{scope.company} · 权限:{scope.permission}
</p>
@@ -877,7 +886,14 @@ function ResultViewContent({
))}
<p>来源:{result.source}</p>
</details>
<div className="aq-answer-actions">
<ResultActions primary={plan.kind !== "clarify" && (
<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{mobile ? (active ? "当前追问范围" : "继续追问") : (active ? "正在基于此结果追问" : "基于此结果追问")}
</button>
)}>
{plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
<button
disabled={disabled || saving}
@@ -888,14 +904,7 @@ function ResultViewContent({
导出明细
</button>
)}
{plan.kind !== "clarify" && (
<button
disabled={disabled || active || !!correctionMessage}
onClick={activate}
>
{active ? "正在基于此结果追问" : "基于此结果追问"}
</button>
)}
<button disabled={saving || flagged} onClick={() => void flag()}>
{flagged ? "已记录" : "未解决我的问题"}
</button>
@@ -910,7 +919,7 @@ function ResultViewContent({
<Copy size={14} />
复制完整结果
</button>
</div>
</ResultActions>
{notice && (
<p className="aq-notice" role="status">
{notice}
@@ -3,6 +3,8 @@
import { useEffect, useId, useState } from "react";
import { Check, ChevronDown, CircleAlert, LoaderCircle, Pause, Sparkles } from "lucide-react";
import { useMobileLayout } from '../useMobileLayout';
export function ThinkingState({
steps,
working,
@@ -18,6 +20,7 @@ export function ThinkingState({
stopped?: boolean;
outcome?: string;
}) {
const mobile = useMobileLayout();
const traceId = useId();
const [manualExpanded, setExpanded] = useState<boolean | null>(null);
const [elapsed, setElapsed] = useState(0);
@@ -28,7 +31,7 @@ export function ThinkingState({
const timer = window.setInterval(update, 1000);
return () => window.clearInterval(timer);
}, [working, started]);
const expanded = manualExpanded ?? working;
const expanded = manualExpanded ?? (working && !mobile);
const StatusIcon = working ? LoaderCircle : stopped ? Pause : failed ? CircleAlert : Sparkles;
return (
<div className={`aq-thinking${working ? " aq-thinking-working" : ""}`} data-state={working ? "working" : stopped ? "stopped" : failed ? "failed" : "complete"}>
@@ -41,7 +44,7 @@ export function ThinkingState({
<StatusIcon size={16} className={working ? "aq-spin" : ""} aria-hidden="true" />
<span className={`aq-thinking-title${working ? " aq-shimmer" : ""}`}>
{working
? "正在分析"
? (mobile ? steps.at(-1) || "正在分析" : "正在分析")
: stopped
? "已由用户停止"
: failed
@@ -0,0 +1,122 @@
/* Mobile presentation only. Keep desktop analysis density unchanged. */
.aq-context-disclosure > summary { display: none; }
@media (max-width: 767px) {
.aq-page { font-size: 14px; }
.aq-page .aq-turns { padding: 16px 16px 24px; }
.aq-page .aq-result, .aq-page .aq-compound-result { margin-left: 0; }
.aq-page .aq-question { margin-bottom: 16px; }
.aq-page .aq-question p { font-size: 15px; line-height: 1.65; }
.aq-page .aq-thinking { border: 0; border-radius: 8px; padding: 0 8px; margin-bottom: 8px; }
.aq-page .aq-thinking > button { font-size: 13px; }
.aq-page .aq-thinking-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.aq-page .aq-thinking-meta { font-size: 12px; }
.aq-page .aq-query-heading { position: static; padding: 4px 0 8px; margin-bottom: 10px; }
.aq-page .aq-current-path { flex-wrap: wrap; overflow: visible; font-size: 13px; gap: 4px 6px; }
.aq-page .aq-current-path > span { flex-shrink: 1; white-space: normal; }
.aq-page .aq-current-path > .aq-path-current { position: static; }
.aq-page .aq-query-navigation { flex-wrap: wrap; font-size: 12px; }
.aq-page .aq-query-navigation > span { font-size: 12px; }
.aq-page .aq-query-navigation > button, .aq-page .aq-path-history > summary { min-height: 44px; font-size: 13px; }
.aq-page .aq-path-history nav button { min-height: 44px; font-size: 13px; }
.aq-page .aq-result .aq-answer { font-size: 15px; line-height: 1.75; margin: 0 0 12px; }
.aq-page .aq-generated-block { margin: 12px 0; }
.aq-page .aq-generated-block h4 { font-size: 14px; }
.aq-page .aq-generated-metrics, .aq-page .aq-primary-metrics,
.aq-page .aq-primary-metrics:has(> div:nth-child(4):last-child) { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.aq-page .aq-generated-metrics > div { padding: 10px 12px; border: 0; border-radius: 8px; }
.aq-page .aq-generated-metrics span, .aq-page .aq-metrics span { font-size: 13px; line-height: 1.5; }
.aq-page .aq-generated-metrics strong, .aq-page .aq-metrics strong { font-size: 26px; margin-top: 3px; }
.aq-page .aq-generated-metrics small, .aq-page .aq-metrics small { font-size: 12px; }
.aq-page .aq-primary-metrics > div { border: 0; padding: 8px 10px !important; background: var(--aq-inset); border-radius: 8px; }
.aq-mobile-visual { margin: 12px 0; border: 1px solid var(--aq-line); border-radius: 10px; padding: 4px 12px 8px; }
.aq-mobile-visual-header { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
.aq-mobile-visual-header > h4 { font-size: 14px; font-weight: 600; margin: 0; min-width: 0; }
.aq-mobile-visual-header > div { display: flex; flex-shrink: 0; gap: 2px; }
.aq-mobile-visual-header button { min-height: 44px; min-width: 44px; padding: 5px 6px; font-size: 13px; color: var(--aq-muted); }
.aq-mobile-visual-header button[aria-pressed="true"] { color: var(--aq-accent); font-weight: 600; text-decoration: underline; text-underline-offset: 7px; }
.aq-mobile-ranking { display: grid; gap: 4px; }
.aq-mobile-ranking > button:not(.aq-generated-expand), .aq-mobile-ranking-item { width: 100%; min-height: 48px; display: block; padding: 6px 0; text-align: left; }
.aq-mobile-ranking-line { display: flex; gap: 8px; align-items: center; font-size: 14px; line-height: 1.5; }
.aq-mobile-ranking-line > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.aq-mobile-ranking-line > strong { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }
.aq-mobile-ranking-line > svg { color: var(--aq-muted); }
.aq-mobile-ranking-track { display: block; height: 5px; border-radius: 3px; background: var(--aq-inset); margin-top: 6px; overflow: hidden; }
.aq-mobile-ranking-track > span { height: 100%; display: block; background: var(--aq-accent); opacity: .65; border-radius: inherit; }
.aq-page .aq-generated-expand { min-height: 44px; font-size: 13px; }
.aq-mobile-data-list { display: grid; gap: 8px; padding: 4px 0; }
.aq-mobile-data-row { min-width: 0; padding: 10px 12px; background: var(--aq-inset); border-radius: 8px; font-size: 14px; line-height: 1.6; }
.aq-mobile-data-values { display: grid; grid-template-columns: minmax(0, 1.35fr) repeat(2, minmax(0, 1fr)); gap: 8px; }
.aq-mobile-data-values span { display: block; font-size: 12px; color: var(--aq-muted); margin-bottom: 2px; }
.aq-mobile-data-values strong { display: block; font-size: 14px; font-weight: 550; overflow-wrap: anywhere; }
.aq-mobile-data-values > div:only-child { grid-column: 1 / -1; }
.aq-mobile-data-row dl { display: grid; gap: 7px; margin: 8px 0; }
.aq-mobile-data-row dl > div { display: grid; grid-template-columns: 80px minmax(0, 1fr); gap: 8px; }
.aq-mobile-data-row dt { color: var(--aq-muted); font-size: 13px; }
.aq-mobile-data-row dd { margin: 0; overflow-wrap: anywhere; }
.aq-mobile-data-footer { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 0 8px; justify-content: space-between; margin-top: 4px; }
.aq-mobile-data-footer > details[open] { width: 100%; }
.aq-mobile-data-row summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; color: var(--aq-muted); font-size: 13px; }
.aq-mobile-data-row summary::after { content: '+'; margin-left: 8px; }
.aq-mobile-data-row details[open] > summary::after { content: '−'; }
.aq-mobile-data-footer > button { min-height: 44px; color: var(--aq-accent); font-size: 13px; }
.aq-mobile-record-heading { display: flex; gap: 8px; justify-content: space-between; align-items: center; }
.aq-mobile-record-heading > strong { white-space: nowrap; font-size: 16px; }
.aq-mobile-record-heading > span { font-size: 12px; color: var(--aq-muted); }
.aq-mobile-data-row > p { margin: 5px 0 0; color: var(--aq-muted); font-size: 13px; }
.aq-page .aq-generated-pagination, .aq-page .aq-pagination { font-size: 12px; padding: 6px 0; }
.aq-page .aq-generated-pagination > button, .aq-page .aq-pagination > button { min-width: 44px; min-height: 44px; }
.aq-mobile-actions { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; margin: 4px 0 10px; }
.aq-mobile-actions > button { min-height: 44px; padding: 4px 0; color: var(--aq-accent); font-size: 13px; }
.aq-mobile-actions > button:disabled { opacity: 1; color: var(--aq-muted); cursor: default; }
.aq-more-actions > summary { display: flex; align-items: center; gap: 5px; min-height: 44px; cursor: pointer; color: var(--aq-muted); font-size: 13px; }
.aq-more-actions[open] { width: 100%; }
.aq-more-actions[open] > summary { justify-content: flex-end; }
.aq-page .aq-more-actions .aq-answer-actions { margin: 0; display: flex; flex-wrap: wrap; gap: 4px 16px; }
.aq-page .aq-more-actions .aq-answer-actions > button { font-size: 13px; min-height: 44px; }
.aq-page .aq-more-actions .aq-answer-actions > span { font-size: 12px; }
.aq-page .aq-result .aq-result-definitions > summary, .aq-page .aq-result .aq-secondary-details > summary,
.aq-page .aq-result .aq-advanced-controls > summary, .aq-page .aq-result .aq-extra-metrics > summary { min-height: 44px; font-size: 13px; line-height: 1.6; display: flex; align-items: center; }
.aq-page .aq-result-definitions, .aq-page .aq-generated-narrative { font-size: 14px; line-height: 1.75; }
.aq-page .aq-followups { gap: 8px; }
.aq-page .aq-followups > span { width: 100%; font-size: 12px; }
.aq-page .aq-followups > button { min-height: 44px; font-size: 13px; text-align: left; }
.aq-page .aq-generated-drill-targets { max-height: none; overflow: visible; gap: 8px; }
.aq-page .aq-generated-drill-targets button { min-height: 44px; font-size: 13px; }
.aq-page .aq-result .aq-table-scroll { max-height: none; overflow-y: visible; }
.aq-page .aq-table-scroll th, .aq-page .aq-table-scroll td { font-size: 13px; white-space: normal; overflow-wrap: anywhere; padding: 10px 8px; }
.aq-page .aq-table-scroll th { position: static; }
.aq-page .aq-result .aq-bar-row { min-height: 44px; grid-template-columns: 75px minmax(15px, 1fr) 36px 40px 13px; font-size: 13px !important; gap: 6px !important; }
.aq-page .aq-bar-name { white-space: normal; overflow-wrap: anywhere; }
.aq-page .aq-result .aq-view-tabs button, .aq-page .aq-result .aq-more { min-height: 44px; font-size: 13px; }
.aq-page .aq-record-toolbar input { font-size: 16px; min-height: 44px; }
.aq-page .aq-section-preview > strong { font-size: 14px; }
.aq-page .aq-section-preview > span { font-size: 13px; }
.aq-page .aq-section-overview > button { padding: 10px; }
.aq-page .aq-compound-toolbar { font-size: 12px; }
.aq-page .aq-compound-toolbar button { min-height: 44px; font-size: 13px; }
.aq-page .aq-context-disclosure { margin-bottom: 4px; min-width: 0; }
.aq-page .aq-context-disclosure > summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 44px; color: var(--aq-muted); font-size: 13px; cursor: pointer; }
.aq-context-disclosure > summary::-webkit-details-marker { display: none; }
.aq-context-disclosure > summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.aq-context-disclosure[open] > summary > span { white-space: normal; }
.aq-page .aq-context { flex-wrap: wrap; overflow: visible; padding: 4px 0; gap: 6px; max-height: 160px; overflow-y: auto; }
.aq-page .aq-context > * { flex-shrink: 1; }
.aq-page .aq-context .aq-filter-chip { max-width: 100%; font-size: 13px; overflow-wrap: anywhere; white-space: normal; }
.aq-page .aq-context button { min-height: 44px; min-width: 44px; }
.aq-page .aq-context .aq-topic-button { padding: 4px 10px; font-size: 13px; }
.aq-page .aq-context-heading { font-size: 12px; }
.aq-page .aq-composer-area { border-top: 1px solid var(--aq-line); }
.aq-page .aq-composer-foot { font-size: 11px; }
.aq-page .aq-latest-answer { min-height: 44px; font-size: 13px; }
}
@media (max-width: 767px) {
.aq-page .aq-header h1 { font-size: 16px; }
.aq-page .aq-scope, .aq-page .aq-compound-heading > div > span,
.aq-page .aq-section-number, .aq-page .aq-generated-notice,
.aq-page .aq-generated-empty, .aq-page .aq-result-unit,
.aq-page .aq-chart-caption, .aq-page .aq-bar-share,
.aq-page .aq-model, .aq-page .aq-record-toolbar,
.aq-page .aq-compound-notice, .aq-page .aq-inline-progress { font-size: 12px; }
.aq-page .aq-secondary-details[open] > p { font-size: 14px; line-height: 1.75; margin-bottom: 8px; }
.aq-page .aq-mobile-record-heading { flex-wrap: wrap; }
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AssistantResult, PresentationBlock } from '../../../shared/assets/assistant';
import { mobileBlockGroups, mobileResultSummary } from './mobile-presentation';
const result: AssistantResult = {
plan: { kind: 'assets', category: 'inventory', groupBy: 'region', view: 'chart', filters: { type: ['4.5T'] } },
title: '库存', summary: '完整说明和统计限制。', source: '资产台账', fetchedAt: '', rows: [],
metrics: [{ label: '库存车辆', value: 200, unit: '台' }, { label: '其中异动', value: 8, unit: '台' }],
groups: [{ name: '华东', count: 125, share: 62.5, operating: 0, rate: 0 }, { name: '华南', count: 75, share: 37.5, operating: 0, rate: 0 }],
};
test('mobile summary preserves computed total and share without adding subset metrics', () => {
assert.equal(mobileResultSummary(result), '库存车辆 200 台;华东最多,125 台(62.5%)。');
assert.equal(mobileResultSummary({ ...result, groups: [...result.groups].reverse() }), mobileResultSummary(result));
});
test('partial, exception, comparison, briefing and simple count answers retain their qualification', () => {
for (const outcome of ['partial', 'empty', 'unsupported'] as const) assert.equal(mobileResultSummary({ ...result, outcome }), result.summary);
for (const analysis of ['compare', 'exceptions', 'briefing'] as const) assert.equal(mobileResultSummary({ ...result, plan: { ...result.plan, analysis } }), result.summary);
assert.equal(mobileResultSummary({ ...result, conciseCount: true }), result.summary);
assert.equal(mobileResultSummary({ ...result, metrics: [] }), result.summary);
});
test('chart/table toggles only combine identical data and drill semantics; unrelated fields remain reachable', () => {
const blocks: PresentationBlock[] = [
{ id: 'chart', kind: 'chart', dataset: 'groups', interactionId: 'region' },
{ id: 'table-other', kind: 'table', dataset: 'groups', interactionId: 'type' },
{ id: 'table', kind: 'table', dataset: 'groups', interactionId: 'region', columns: [{ field: 'pending', label: '待交车' }] },
{ id: 'rows', kind: 'table', dataset: 'rows' },
{ id: 'second-chart', kind: 'chart', dataset: 'groups', interactionId: 'region' },
{ id: 'explanation', kind: 'narrative', text: '统计限制' },
];
const grouped = mobileBlockGroups(blocks);
assert.equal(grouped[0].table, blocks[2]);
assert.equal(grouped.find(entry => entry.block.id === 'second-chart')?.table, undefined);
assert.deepEqual(grouped.map(entry => entry.block.id), ['chart', 'table-other', 'rows', 'second-chart', 'explanation']);
assert.equal(grouped.flatMap(entry => [entry.block, ...(entry.table ? [entry.table] : [])]).length, blocks.length);
});
@@ -0,0 +1,26 @@
import type { AssistantResult, PresentationBlock } from '../../../shared/assets/assistant';
/** Summarise computed values only; never add totals across independent scopes. */
export function mobileResultSummary(result: AssistantResult): string {
if (result.plan.kind !== 'assets' || (result.outcome && result.outcome !== 'complete') || (result.plan.analysis && result.plan.analysis !== 'distribution') || result.conciseCount) return result.summary;
const total = result.metrics.find(metric => metric.unit === '台' && Number.isFinite(metric.value));
if (!total) return result.summary;
const largest = result.groups.filter(group => Number.isFinite(group.count)).reduce<(typeof result.groups)[number] | undefined>((lead, group) => !lead || group.count > lead.count ? group : lead, undefined);
const headline = `${total.label} ${total.value.toLocaleString('zh-CN')} 台`;
if (result.groups.length < 2 || !largest || !largest.count) return `${headline}。`;
const share = largest.share;
return `${headline};${largest.name}最多,${largest.count.toLocaleString('zh-CN')} 台${typeof share === 'number' && Number.isFinite(share) ? `(${share}%)` : ''}。`;
}
export type MobileBlockGroup = { block: PresentationBlock; table?: PresentationBlock };
/** Pair one chart and one table only when they share both data and drill semantics. */
export function mobileBlockGroups(blocks: PresentationBlock[]): MobileBlockGroup[] {
const paired = new Set<string>();
const pairs = new Map<string, PresentationBlock>();
for (const block of blocks) {
if (block.kind !== 'chart' || !block.dataset) continue;
const table = blocks.find(candidate => candidate.kind === 'table' && !paired.has(candidate.id) && candidate.dataset === block.dataset && candidate.interactionId === block.interactionId);
if (table) { paired.add(table.id); pairs.set(block.id, table); }
}
return blocks.filter(block => !paired.has(block.id)).map(block => ({ block, table: pairs.get(block.id) }));
}
@@ -28,7 +28,7 @@ function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abn
<p className="mt-1 text-xs text-slate-500">{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location === '其他' ? '对接中' : vehicle.location}</p>
<dl className="mt-4 space-y-3 text-sm">
{details(vehicle).map((value, index) => <div key={labels[index]} className="grid grid-cols-[6rem_minmax(0,1fr)] gap-2">
<dt className="text-slate-500">{labels[index]}</dt>
<dt className="text-slate-500">{pending && index === 1 ? "客户(已脱敏)" : labels[index]}</dt>
<dd className="whitespace-pre-wrap break-words font-medium text-slate-800 [overflow-wrap:anywhere]"><Blur>{value}</Blur></dd>
</div>)}
</dl>
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { ArrowLeft, X } from 'lucide-react';
import type { FlowDetailItem, FlowType } from '../api';
import type { FlowSelection } from '../model';
@@ -12,6 +12,10 @@ import { AnalysisExportButton } from './analysis/AnalysisExportButton';
import { downloadAnalysisWorkbook, statisticsExportRows } from '../analysis-export';
import './analysis/asset-analysis.css';
import './flow-analysis-modal.css';
import './analysis/mobile-analysis.css';
import { MobileAnalysisControls } from './analysis/MobileAnalysisControls';
import { MobileGroupedTable } from './analysis/MobileGroupedTable';
import { useMobileLayout } from '../useMobileLayout';
interface FlowAnalysisModalProps {
selectedFlow: FlowSelection | null;
@@ -58,6 +62,7 @@ export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
}
function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
const mobile = useMobileLayout();
const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
const [mergeParents, setMergeParents] = useState(true);
const mergeActive = mergeParents && dimensions.length > 1;
@@ -87,6 +92,9 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
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 overviewScroll = useRef(0);
const openDetails = (key: string) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailKey(key); };
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
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(() => ({
@@ -96,7 +104,7 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
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 groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails, preserveOnDisable: true });
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]);
@@ -124,19 +132,19 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
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>
<div className="bi-flow-modal-header-row">{mobile && <button className="bi-flow-mobile-back" aria-label={showDetails ? "返回交还车统计" : "返回资产管理"} onClick={() => showDetails ? setDetailKey(null) : close()}><ArrowLeft size={20} /></button>}<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>
{!mobile && <p className="bi-flow-modal-definition">交车包含替换交车;业务类型按实际记录拆分,同车多笔业务分别计次。</p>}
</header>
<div ref={scrollRoot} className="bi-flow-modal-body">
{!showDetails ? <div className="bi-flow-modal-controls">
{!mobile && (!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> : <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>
@@ -144,14 +152,19 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
{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}
{mobile && !showDetails && (filteredRows.length > 0 || activeFilters.length > 0) && <MobileAnalysisControls available={DIMENSIONS.map(d => ({ ...d, options: filterOptions[d.key] }))} dimensions={dimensions} filters={filters} defaults={['date']} apply={(nextDimensions, nextFilters) => { setDimensions(nextDimensions as Dimension[]); setFilters(nextFilters); resetResult(); }} />}
{mobile && showDetails && <p className="analysis-mobile-detail-scope">{detailLabel}</p>}
<div className="bi-flow-modal-section-heading"><div>{(!mobile || showDetails) && <h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4>}<p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : mobile ? `共 ${countLabel(groups.length)} 组 · ${countLabel(filteredRows.length)} 车次` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
{!mobile && !showDetails && dimensions.length > 1 ? <label className="bi-flow-modal-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label> : null}
<div className="analysis-export-actions">
<AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} />
{(!mobile || selectedRows.length > 0) && <AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} />}
</div>
</div>
{!showDetails ? groups.length ? <>
{mobile ? <MobileGroupedTable dimensions={dimensions.map(d => DIMENSION_LABELS[d])} unit="车次" merged={mergeActive} setMerged={setMergeParents} spans={groupSpans} parentTotals={parentTotals}
rows={shownGroups.map(group => ({ key: group.key, label: group.values.map((value, i) => dimensionLabel(dimensions[i], value)).join('、') || '当前筛选范围', values: group.values.map((value, i) => dimensionLabel(dimensions[i], value)), count: group.rows.length, share: group.rows.length / filteredRows.length * 100 }))}
onDetails={index => openDetails(shownGroups[index].key)} /> :
<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) => {
@@ -159,16 +172,18 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
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>
<td className="bi-flow-modal-action"><button type="button" aria-label={`查看${group.values.map((value, i) => dimensionLabel(dimensions[i], value)).join('、') || '当前筛选范围'}明细`} onClick={() => openDetails(group.key)}>查看明细</button></td>
</tr>)}
</tbody></table></div>
</tbody></table></div>}
<ProgressiveRowsStatus {...groupProgress} unit="组" />
</> : <div className="bi-flow-modal-empty">当前筛选范围暂无交还车数据,请调整筛选。</div>
</> : <div className="bi-flow-modal-empty">{activeFilters.length ? '当前筛选范围暂无交还车数据,可调整筛选。' : '当前时段暂无交还车记录。'}</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><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><td>{item.contractNo || '—'}</td><td>{item.projectName || '—'}</td></tr>)}</tbody></table></div>
{mobile ? <div className="analysis-vehicle-cards" data-testid="asset-flow-final-details">{shownDetails.map(item => <article className="analysis-vehicle-card" key={item.id}><header><h4>{item.plateNumber || '未登记车牌'}</h4><span>{FLOW_META[item.type].label}</span></header><p>{item.eventTime || item.date} · {item.department || '未填写部门'}</p><p>{item.customerName || '未填写客户'}</p><details><summary>业务资料</summary><dl>{[['运维签章时间', item.eventTime], ['建单时间', item.submitTime], ['业务负责人', item.manager], ['合同', item.contractNo], ['项目名称', item.projectName]].map(([label,value]) => <div key={label}><dt>{label}</dt><dd>{value || '未提供'}</dd></div>)}</dl></details></article>)}</div> :
<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><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><td>{item.contractNo || '—'}</td><td>{item.projectName || '—'}</td></tr>)}</tbody></table></div>}
<ProgressiveRowsStatus {...detailProgress} unit="条" />
</> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>}
</div>
{mobile && <footer className="analysis-footer"><details><summary>统计口径</summary><p>按运维签章时间统计。交车包含替换交车;同车多笔业务分别计次。仅展示当前可查看范围与筛选条件内的记录。</p></details></footer>}
</div>
</div>;
}
@@ -1,7 +1,9 @@
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';
import { ANALYSIS_META, DIMENSION_LABELS, filterCustomCells, getDefaultDimensions, getDimensionValueLabel, type AnalysisFilterValues, type AnalysisView } from '../../analytics-model';
import { useMobileLayout } from '../../useMobileLayout';
import { MobileAnalysisControls } from './MobileAnalysisControls';
type Props = {
view: AnalysisView;
@@ -15,6 +17,7 @@ type Props = {
};
export function AnalysisControls({ view, cells, available, dimensions, setDimensions, filters, setFilters, reset }: Props) {
const mobile = useMobileLayout();
const root = useRef<HTMLElement>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
@@ -56,6 +59,14 @@ export function AnalysisControls({ view, cells, available, dimensions, setDimens
if (draft.length) next[editor] = draft; else delete next[editor];
setFilters(next); closeEditor();
};
const mobileOptions = useMemo(() => mobile ? available.map(key => {
const other = { ...filters }; delete other[key];
const counts = new Map<string, number>();
filterCustomCells(cells, other, metric).forEach(cell => counts.set(cell[key], (counts.get(cell[key]) || 0) + cell.quantity));
for (const value of filters[key] || []) if (!counts.has(value)) counts.set(value, 0);
return { key, label: DIMENSION_LABELS[key], options: [...counts].map(([value, count]) => ({ value, count, label: getDimensionValueLabel(key, value) })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN')) };
}) : [], [mobile, available, cells, filters, metric]);
if (mobile) return <MobileAnalysisControls available={mobileOptions} dimensions={dimensions} filters={filters} defaults={getDefaultDimensions(view)} apply={(nextDimensions, nextFilters) => { setDimensions(nextDimensions as Dimension[]); setFilters(nextFilters); }} />;
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="选择分析维度">
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, 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';
@@ -15,8 +15,12 @@ import type { VehicleListItem } from '../../types';
import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails';
import { useDialogFocus } from '../useDialogFocus';
import { AnalysisExportButton } from './AnalysisExportButton';
import { MobileAnalysisControls } from './MobileAnalysisControls';
import { MobileGroupedTable } from './MobileGroupedTable';
import { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export';
import { useMobileLayout } from '../../useMobileLayout';
import './asset-analysis.css';
import './mobile-analysis.css';
type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void };
const number = (value: number) => value.toLocaleString('zh-CN');
@@ -36,6 +40,7 @@ export function AssetAnalysisModal({ selection, subject, close }: Props) {
}
function AnalysisDialog({ selection, subject, close }: Props & { selection: VehicleModalSelection }) {
const mobile = useMobileLayout();
const dialog = useRef<HTMLDivElement>(null);
const scrollRoot = useRef<HTMLDivElement>(null);
useDialogFocus(true, dialog, close);
@@ -51,6 +56,9 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
const [mergeParents, setMergeParents] = useState(true);
const [detailGroup, setDetailGroup] = useState<CustomAnalysisRow | null>(null);
const showDetails = detailGroup !== null;
const overviewScroll = useRef(0);
const openDetails = (row: CustomAnalysisRow) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailGroup(row); };
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
const [vehicles, setVehicles] = useState<VehicleListItem[]>([]);
const [detailBusy, setDetailBusy] = useState(false);
const [detailError, setDetailError] = useState('');
@@ -107,7 +115,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
: 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 groupProgress = useProgressiveRows({ total: orderedGroups.length, rootRef: scrollRoot, resetKey: orderedGroups, enabled: !showDetails && !busy, batchSize: 40, preserveOnDisable: true });
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]);
@@ -132,17 +140,21 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
};
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>}
{(!mobile || request.kind !== 'weekly' || busy || error) && <header className="analysis-header">{mobile && <button type="button" aria-label={showDetails ? "返回统计分析" : "返回资产管理"} onClick={() => showDetails ? setDetailGroup(null) : close()}><ArrowLeft size={20} /></button>}<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} title={title} scopeLabel={scopeLabel} close={close} /> : <>
{showDetails && !mobile && <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} />
{!mobile && <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"><AnalysisExportButton disabled={!analysis.total} onExport={exportWorkbook} />{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> : <>
{mobile && (analysis.total > 0 || Object.keys(filterValues).length > 0) && <AnalysisControls view={view} cells={data?.cells ?? []} available={available} dimensions={dimensions} setDimensions={updateDimensions} filters={filterValues} setFilters={updateFilters} reset={reset} />}
{!mobile && view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
<div className={`analysis-table-tools analysis-overview-tools${!analysis.total ? ' is-empty' : ''}`}><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options"><AnalysisExportButton disabled={!analysis.total} onExport={exportWorkbook} />{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>{Object.keys(filterValues).length ? '没有符合条件的数据' : view === 'abnormal' ? '当前无异动车辆' : view === 'pending' ? '当前无待交车辆' : '当前范围暂无车辆'}</h3><p>{Object.keys(filterValues).length ? '可移除筛选条件后重新查看。' : '当前可查看范围内没有相关记录。'}</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
{mobile ? <MobileGroupedTable dimensions={analysis.dimensions.map(d => DIMENSION_LABELS[d])} unit="台" merged={merged} setMerged={setMergeParents} spans={spans} parentTotals={parentTotals}
rows={shownGroups.map(row => ({ key: row.key, label: row.label, values: analysis.dimensions.map(d => <Blur key={d}>{getDimensionValueLabel(d, row.values[d] || '')}</Blur>), count: row.count, share: row.share, note: view === 'inventory' && (row.totals.pending || row.totals.abnormal) ? [row.totals.pending ? `待交 ${row.totals.pending}` : '', row.totals.abnormal ? `异动 ${row.totals.abnormal}` : ''].filter(Boolean).join(' · ') : undefined }))}
onDetails={index => openDetails(shownGroups[index])} onFilter={index => narrowToGroup(shownGroups[index])} /> : <>
<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}>
@@ -153,14 +165,15 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
<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>
<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={() => openDetails(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">
{showDetails && <div className="analysis-details">{mobile && <p className="analysis-mobile-detail-scope">{detailGroup.label} · 当前分组</p>}
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><AnalysisExportButton disabled={detailBusy || !!detailError || !visibleVehicles.length} onExport={exportWorkbook} /><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>}
@@ -169,19 +182,26 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
</>}
</div>}
</div>
<footer className="analysis-footer"><span>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</span><span>{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}</span></footer>
<footer className="analysis-footer">{mobile ? <details><summary>统计口径与数据时间</summary><p>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</p><p>同维度筛选为“或”,不同维度为“且”。仅展示当前入口与筛选条件内的车辆。</p>{view === 'abnormal' && <p>已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}</details> : <><span>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</span><span>{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}</span></>}</footer>
</>}
</div>
</div>;
}
function VehicleRows({ vehicles, inventory = false }: { vehicles: VehicleListItem[]; inventory?: boolean }) {
const mobile = useMobileLayout();
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(' · ');
};
if (mobile) return <div className="analysis-vehicle-cards" aria-label="车辆明细列表">{vehicles.map(vehicle => <article className="analysis-vehicle-card" key={vehicle.id}>
<header><h4><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></h4><span>{stateLabel(vehicle)}</span></header>
<p>{vehicle.model || `${vehicle.brandLabel || '—'} · ${vehicle.type}`} · {vehicle.city || vehicle.location || '未标注城市'}</p>
{!inventory && vehicle.customerName && <p><Blur>{vehicle.customerName}</Blur></p>}
<details><summary>车辆资料</summary><dl>{[['VIN', vehicle.vin], ['品牌 / 车型', `${vehicle.brandLabel || '—'} · ${vehicle.type}`], ['省份 / 城市', `${vehicle.province || '未标注'} / ${vehicle.city || vehicle.location || '未标注'}`], ...(inventory ? [['运维负责人', vehicle.opsManagers?.trim() || '-']] : [['签约公司', vehicle.orgName], ['部门', vehicle.departmentName], ['业务负责人', vehicle.customerManager]]), ['所属公司', vehicle.subjectOrg]].map(([label, value]) => <div key={label}><dt>{label}</dt><dd><Blur>{value || '未提供'}</Blur></dd></div>)}</dl></details>
</article>)}</div>;
return <div className="analysis-detail-table" tabIndex={0} role="region" aria-label="车辆明细表"><table><thead><tr>{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', ...(inventory ? ['运维负责人'] : ['客户 / 签约公司', '部门 / 业务负责人']), '所属公司'].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>{inventory ? <td><Blur>{vehicle.opsManagers?.trim() || '-'}</Blur></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>;
}
@@ -191,7 +211,8 @@ 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[] }) {
function WeeklyAnalysis({ rows, title, scopeLabel, close }: { rows: WeeklyDetailItem[]; title: string; scopeLabel: string; close: () => void }) {
const mobile = useMobileLayout();
type Dimension = WeeklyDimension;
const dimensionsAvailable = WEEKLY_DIMENSIONS;
const scrollRoot = useRef<HTMLDivElement>(null);
@@ -222,12 +243,15 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
const detailGroup = detailKey === null ? null : grouped.get(detailKey);
const detailRows = detailGroup?.rows ?? EMPTY_WEEKLY_ROWS;
const showDetails = detailKey !== null;
const overviewScroll = useRef(0);
const openDetails = (key: string) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailKey(key); };
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
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 groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails, preserveOnDisable: true });
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]);
@@ -256,6 +280,25 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
const details = selectedRows.map(row => ({ 车牌: row.plate_number, 日期: row.handover_date, 合同类型: row.contract_type, 客户: row.customer_name }));
return downloadAnalysisWorkbook(statistics, details, `交还车分析-${detailGroup ? groupLabel(detailGroup) : '当前筛选结果'}`);
};
if (mobile) return <>
<header className="analysis-header"><button aria-label={showDetails ? '返回统计分析' : '返回资产管理'} onClick={() => showDetails ? setDetailKey(null) : close()}><ArrowLeft size={20} /></button><div><h2>{title}</h2><p>{scopeLabel}</p></div><button aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>
<div ref={scrollRoot} className="analysis-scroll">
<div className="analysis-result-bar" role="status"><span>当前结果 <strong>{number(showDetails ? detailRows.length : filtered.length)}</strong> 车次</span>{!showDetails && <span>{groups.length} 个分组</span>}</div>
{!showDetails ? <>
{(filtered.length > 0 || activeFilters.length > 0) && <MobileAnalysisControls available={dimensionsAvailable.map(key => ({ key, label: labels[key], options: filterOptions[key] }))} dimensions={dimensions} filters={filters} defaults={['date']} apply={(nextDimensions, nextFilters) => { updateDimensions(nextDimensions as Dimension[]); updateFilters(nextFilters); }} />}
{filtered.length > 0 && <div className="analysis-table-tools"><AnalysisExportButton onExport={exportWorkbook} /></div>}
{groups.length ? <><MobileGroupedTable dimensions={dimensions.map(d => labels[d])} unit="车次" merged={mergeActive} setMerged={setMergeParents} spans={groupSpans} parentTotals={parentTotals}
rows={shownGroups.map(group => ({ key: group.key, label: groupLabel(group), values: group.values.map((entry, i) => label(dimensions[i], entry)), count: group.rows.length, share: group.rows.length / filtered.length * 100 }))}
onDetails={index => openDetails(shownGroups[index].key)} onFilter={index => updateFilters({ ...filters, ...Object.fromEntries(dimensions.map((dimension, i) => [dimension, [shownGroups[index].values[i]]])) })} /><ProgressiveRowsStatus {...groupProgress} unit="组" /></> : <div className="analysis-message"><p>{activeFilters.length ? '没有符合条件的数据,可调整筛选。' : '当前范围暂无交还车记录。'}</p>{activeFilters.length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div>}
</> : <>
<p className="analysis-mobile-detail-scope">{detailGroup ? groupLabel(detailGroup) : '当前分组'}</p>
<div className="analysis-table-tools"><h3>交还车业务明细</h3><AnalysisExportButton disabled={!detailRows.length} onExport={exportWorkbook} /></div>
<div className="analysis-vehicle-cards">{shownDetails.map((row, index) => <article className="analysis-vehicle-card" key={`${row.truck_id}-${index}`}><header><h4><Blur>{row.plate_number || '未登记车牌'}</Blur></h4><span>{row.contract_type || '未标注合同类型'}</span></header><p>{row.handover_date || '日期缺失'}</p><p><Blur>{row.customer_name || '未提供客户'}</Blur></p></article>)}</div>
<ProgressiveRowsStatus {...detailProgress} unit="条" />
</>}
</div>
<footer className="analysis-footer"><details><summary>统计口径</summary><p>按当前入口的周业务记录统计车次,同一车辆多笔记录分别计次。仅展示当前可查看范围和筛选条件内的数据。</p></details></footer>
</>;
return <div ref={scrollRoot} className="analysis-scroll">
{!showDetails ? <>
<div className="analysis-builder">
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type RefObject } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
interface ProgressiveRowsOptions {
total: number;
@@ -6,11 +6,14 @@ interface ProgressiveRowsOptions {
resetKey: unknown;
enabled?: boolean;
batchSize?: number;
preserveOnDisable?: boolean;
}
/** 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]);
export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, batchSize = 40, preserveOnDisable = false }: ProgressiveRowsOptions) {
const resetEnabled = preserveOnDisable || enabled;
const context = useMemo(() => ({ resetKey, enabled: resetEnabled, batchSize }), [resetKey, resetEnabled, batchSize]);
const scrollContext = useRef(context);
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);
@@ -21,8 +24,9 @@ export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, b
}, [enabled, context, total, batchSize]);
useEffect(() => {
if (enabled && rootRef.current) rootRef.current.scrollTop = 0;
}, [context, enabled, rootRef]);
if (enabled && rootRef.current && (!preserveOnDisable || scrollContext.current !== context)) rootRef.current.scrollTop = 0;
scrollContext.current = context;
}, [context, enabled, rootRef, preserveOnDisable]);
useEffect(() => {
const root = rootRef.current;