feat(assets): 优化移动端分析体验并记录 AI 提问日志
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user