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
@@ -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}