Merge concise AI answers and mobile UI refinements into main
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, type ComponentType, type ElementType, Sus
|
||||
import { motion } from 'motion/react';
|
||||
import { Building2, ChevronRight } from 'lucide-react';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import FeedbackFab from './FeedbackFab';
|
||||
import { cn } from '../lib/cn';
|
||||
import { LoadingState } from './ui/surface';
|
||||
|
||||
@@ -96,7 +95,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
}, [user, watermarkNow]);
|
||||
|
||||
return (
|
||||
<div className="enterprise-grid-bg flex min-h-screen">
|
||||
<div className="app-shell enterprise-grid-bg flex min-h-screen">
|
||||
{/* 氢费看板按原型交付;其他模块继续保留全局水印。 */}
|
||||
{!isHydrogenPrototype ? (
|
||||
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden" style={{ opacity: 0.06 }}>
|
||||
@@ -166,7 +165,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
</nav>
|
||||
|
||||
{/* 内容区 */}
|
||||
<main className="min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
|
||||
<main className="app-shell-content min-w-0 flex-1 pb-16 md:ml-20 md:pb-0" style={{ overflowX: 'clip' }}>
|
||||
{!isHydrogenPrototype && (
|
||||
<div className="pointer-events-none fixed left-20 right-0 top-0 z-20 hidden h-12 items-center border-b border-white/60 bg-white/55 px-6 text-xs font-bold text-slate-400 backdrop-blur-xl md:flex">
|
||||
<span className="text-slate-600">羚牛氢能 BI</span>
|
||||
@@ -177,11 +176,10 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||
<Suspense fallback={<div className="p-3 md:p-6"><LoadingState label="正在加载业务模块" /></div>}>
|
||||
{ActiveComponent && <ActiveComponent />}
|
||||
</Suspense>
|
||||
{!isHydrogenPrototype && activeModule !== 'assets' ? <FeedbackFab module={activeModule} /> : null}
|
||||
</main>
|
||||
|
||||
{/* 移动端底部导航 (md 以下) */}
|
||||
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden">
|
||||
<nav className="app-mobile-nav fixed inset-x-0 bottom-0 z-50 border-t border-white/70 bg-white/92 px-3 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-[0_-18px_40px_rgba(15,23,42,0.08)] backdrop-blur-xl md:hidden">
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${modules.length}, minmax(0, 1fr))` }}>
|
||||
{modules.map((m) => {
|
||||
const Icon = m.icon;
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from './model';
|
||||
import { SearchSelect } from '../../components/SearchSelect';
|
||||
import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||
import { AssetsHeader } from './components/AssetsHeader';
|
||||
import { FlowDetailModal } from './components/FlowDetailModal';
|
||||
@@ -1743,7 +1742,6 @@ export default function AssetsModule() {
|
||||
/>
|
||||
|
||||
</div>
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
<button
|
||||
ref={assistantTrigger}
|
||||
type="button"
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from "../../../shared/assets/assistant";
|
||||
import { askAssetAssistant, AssistantRequestError } from "./client";
|
||||
import { fetchAuthenticated } from "../../../auth/api-client";
|
||||
import { DemandGaps } from "./DemandGaps";
|
||||
import { ThinkingState } from "./ThinkingState";
|
||||
import { CompoundResultView } from "./CompoundResultView";
|
||||
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
|
||||
@@ -90,6 +89,23 @@ export default function AssetAssistant({
|
||||
close: () => void;
|
||||
appearance: "light" | "dark";
|
||||
}) {
|
||||
const page = useRef<HTMLElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
const updateViewport = () => {
|
||||
page.current?.style.setProperty('--aq-viewport-height', `${viewport?.height ?? window.innerHeight}px`);
|
||||
page.current?.style.setProperty('--aq-viewport-top', `${viewport?.offsetTop ?? 0}px`);
|
||||
};
|
||||
updateViewport();
|
||||
viewport?.addEventListener('resize', updateViewport);
|
||||
viewport?.addEventListener('scroll', updateViewport);
|
||||
window.addEventListener('resize', updateViewport);
|
||||
return () => {
|
||||
viewport?.removeEventListener('resize', updateViewport);
|
||||
viewport?.removeEventListener('scroll', updateViewport);
|
||||
window.removeEventListener('resize', updateViewport);
|
||||
};
|
||||
}, []);
|
||||
const [theme, setTheme] = useState(appearance);
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const turnsRef = useRef(turns);
|
||||
@@ -110,7 +126,6 @@ export default function AssetAssistant({
|
||||
const [activeResult, setActiveResult] = useState<AssistantResult>();
|
||||
const sessionRef = useRef<string | undefined>(undefined);
|
||||
const sessions = useRef(new Map<string, string | null>());
|
||||
const [showGaps, setShowGaps] = useState(false);
|
||||
const nextId = useRef(0);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
@@ -189,7 +204,6 @@ export default function AssetAssistant({
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
const revealTurn = (id: number) => {
|
||||
setShowGaps(false);
|
||||
setExpandedTurns((old) => new Set(old).add(id));
|
||||
autoFollow.current = null;
|
||||
setViewedTurn(id);
|
||||
@@ -232,7 +246,6 @@ export default function AssetAssistant({
|
||||
action?: AssistantAction,
|
||||
) => {
|
||||
if (!message.trim() || abortRef.current) return;
|
||||
setShowGaps(false);
|
||||
setIndependent(false);
|
||||
const inlineTurn = action && base?.resultId
|
||||
? turnsRef.current.find(turn => !!findConversationResult([turn], base.resultId!))
|
||||
@@ -344,7 +357,6 @@ export default function AssetAssistant({
|
||||
).catch(() => {});
|
||||
sessionRef.current = undefined;
|
||||
setActiveResult(undefined);
|
||||
setShowGaps(false);
|
||||
setTurns([]);
|
||||
setExpandedTurns(new Set());
|
||||
setDraft("");
|
||||
@@ -355,6 +367,7 @@ export default function AssetAssistant({
|
||||
}, [subject]);
|
||||
return (
|
||||
<section
|
||||
ref={page}
|
||||
className={`aq-shell aq-page aq-${theme}`}
|
||||
aria-label="资产 AI 问数"
|
||||
>
|
||||
@@ -388,10 +401,6 @@ export default function AssetAssistant({
|
||||
返回资产总览
|
||||
<ArrowUpRight size={14} />
|
||||
</button>
|
||||
<button className="aq-nav" onClick={() => setShowGaps(true)}>
|
||||
<CircleHelp size={16} />
|
||||
待完善需求
|
||||
</button>
|
||||
<div className="aq-side-label aq-history-label">
|
||||
本次对话<span>{turns.length.toString().padStart(2, "0")}</span>
|
||||
</div>
|
||||
@@ -438,13 +447,6 @@ export default function AssetAssistant({
|
||||
<span className="aq-header-subtitle">你的资产分析助手</span>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="aq-icon-button"
|
||||
onClick={() => setShowGaps(!showGaps)}
|
||||
aria-label="查看待完善需求"
|
||||
>
|
||||
<History size={17} />
|
||||
</button>
|
||||
<button
|
||||
className="aq-icon-button aq-mobile-new"
|
||||
onClick={reset}
|
||||
@@ -509,9 +511,7 @@ export default function AssetAssistant({
|
||||
onTouchMove={() => { autoFollow.current = null; readerRevision.current++; }}
|
||||
onPointerDown={() => { autoFollow.current = null; readerRevision.current++; }}
|
||||
onKeyDown={(event) => { if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) { autoFollow.current = null; readerRevision.current++; } }}>
|
||||
{showGaps ? (
|
||||
<DemandGaps close={() => setShowGaps(false)} />
|
||||
) : !turns.length ? (
|
||||
{!turns.length ? (
|
||||
<div className="aq-welcome">
|
||||
<div className="aq-welcome-mark">
|
||||
<Sparkles size={30} strokeWidth={1.3} />
|
||||
@@ -519,15 +519,14 @@ export default function AssetAssistant({
|
||||
<h2>关于资产,你想了解什么?</h2>
|
||||
<p>
|
||||
用一句话,发现数据里的答案。
|
||||
<br className="aq-mobile-break" />{" "}
|
||||
从全局统计,一路看到每一台车。
|
||||
<span className="aq-welcome-detail"> 从全局统计,一路看到每一台车。</span>
|
||||
</p>
|
||||
<div className="aq-prompt-grid">
|
||||
{PROMPTS.map((p) => (
|
||||
<button key={p.title} onClick={() => void ask(p.text)}>
|
||||
<div>
|
||||
<p.icon size={18} />
|
||||
<ArrowUpRight size={14} />
|
||||
<p.icon size={18} aria-hidden="true" />
|
||||
<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</div>
|
||||
<strong>{p.title}</strong>
|
||||
<span>{p.detail}</span>
|
||||
@@ -711,13 +710,7 @@ export default function AssetAssistant({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="aq-waiting">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -169,7 +169,7 @@ export function CompoundResultView({
|
||||
{result.gap && (
|
||||
<p className="aq-compound-notice">
|
||||
{result.gap.recorded
|
||||
? "未满足的需求已记录,可在待完善需求中查看。"
|
||||
? "未满足的需求已记录。"
|
||||
: "未满足的需求暂未保存。"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -281,7 +281,7 @@ function ResultViewContent({
|
||||
);
|
||||
return (
|
||||
<div className={`aq-result ${active ? "aq-result-active" : ""}`}>
|
||||
{plan.kind !== "clarify" && (
|
||||
{plan.kind !== "clarify" && !result.conciseCount && (
|
||||
<header
|
||||
className="aq-query-heading"
|
||||
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
|
||||
@@ -362,7 +362,7 @@ function ResultViewContent({
|
||||
>
|
||||
{result.summary}
|
||||
</p>
|
||||
{!!visibleMetrics.length && (
|
||||
{!result.conciseCount && !!visibleMetrics.length && (
|
||||
<div className="aq-metrics aq-primary-metrics">
|
||||
{visibleMetrics.map((metric) => (
|
||||
<div key={metric.label}>
|
||||
@@ -375,7 +375,7 @@ function ResultViewContent({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!!extraMetrics.length && (
|
||||
{!result.conciseCount && !!extraMetrics.length && (
|
||||
<details className="aq-extra-metrics">
|
||||
<summary>查看其余 {extraMetrics.length} 项指标</summary>
|
||||
<div>
|
||||
@@ -405,11 +405,11 @@ function ResultViewContent({
|
||||
{result.gap && (
|
||||
<p className="aq-gap-note">
|
||||
{result.gap.recorded
|
||||
? "这个需求已记下,可在「待完善需求」中查看。"
|
||||
? "这个需求已记下。"
|
||||
: "可以点击下方「未解决我的问题」记录需求。"}
|
||||
</p>
|
||||
)}
|
||||
{plan.kind !== "clarify" && (
|
||||
{plan.kind !== "clarify" && !result.conciseCount && (
|
||||
<>
|
||||
<div className="aq-result-card">
|
||||
{result.inventoryBreakdown && display !== "metrics" && (
|
||||
@@ -849,6 +849,11 @@ function ResultViewContent({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{result.conciseCount && result.metrics[0]?.value > 0 && (
|
||||
<button className="aq-more" disabled={disabled} onClick={() => run({ type: "view", view: "list" }, "查看车辆明细")}>
|
||||
查看车辆明细 <ArrowUpRight size={14} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<div className="aq-result-footer">
|
||||
<details className="aq-result-definitions">
|
||||
<summary>
|
||||
@@ -873,7 +878,7 @@ function ResultViewContent({
|
||||
<p>来源:{result.source}</p>
|
||||
</details>
|
||||
<div className="aq-answer-actions">
|
||||
{plan.kind !== "clarify" && (
|
||||
{plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
|
||||
<button
|
||||
disabled={disabled || saving}
|
||||
onClick={() => void download()}
|
||||
@@ -912,7 +917,7 @@ function ResultViewContent({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!!suggestions.length && (
|
||||
{!result.conciseCount && !!suggestions.length && (
|
||||
<div className="aq-followups">
|
||||
<span>{plan.kind === "clarify" ? "你可以这样继续" : "继续分析"}</span>
|
||||
{suggestions.slice(0, 4).map((question) => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Adapted from Beautiful UI's MIT-licensed ThinkingState (Steps).
|
||||
// Uses real server progress rather than the reference's timed demo sequence.
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronDown, LoaderCircle, Sparkles } from "lucide-react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Check, ChevronDown, CircleAlert, LoaderCircle, Pause, Sparkles } from "lucide-react";
|
||||
|
||||
export function ThinkingState({
|
||||
steps,
|
||||
@@ -18,27 +18,30 @@ export function ThinkingState({
|
||||
stopped?: boolean;
|
||||
outcome?: string;
|
||||
}) {
|
||||
const traceId = useId();
|
||||
const [manualExpanded, setExpanded] = useState<boolean | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!working) return;
|
||||
const update = () => setElapsed((Date.now() - started) / 1000);
|
||||
update();
|
||||
const timer = window.setInterval(update, 100);
|
||||
const timer = window.setInterval(update, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [working, started]);
|
||||
const expanded = manualExpanded ?? false;
|
||||
const expanded = manualExpanded ?? working;
|
||||
const StatusIcon = working ? LoaderCircle : stopped ? Pause : failed ? CircleAlert : Sparkles;
|
||||
return (
|
||||
<div className="aq-thinking">
|
||||
<div className={`aq-thinking${working ? " aq-thinking-working" : ""}`} data-state={working ? "working" : stopped ? "stopped" : failed ? "failed" : "complete"}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={traceId}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
<span className={working ? "aq-shimmer" : ""}>
|
||||
<StatusIcon size={16} className={working ? "aq-spin" : ""} aria-hidden="true" />
|
||||
<span className={`aq-thinking-title${working ? " aq-shimmer" : ""}`}>
|
||||
{working
|
||||
? steps.at(-1) || "正在分析"
|
||||
? "正在分析"
|
||||
: stopped
|
||||
? "已由用户停止"
|
||||
: failed
|
||||
@@ -55,7 +58,10 @@ export function ThinkingState({
|
||||
? "已回答"
|
||||
: "分析完成"}
|
||||
</span>
|
||||
{elapsed > 0 && <span className="aq-time">{elapsed.toFixed(1)}s</span>}
|
||||
<span className="aq-thinking-meta">
|
||||
{steps.length > 0 && <span>{steps.length} 个步骤</span>}
|
||||
{elapsed > 0 && <span className="aq-time">{Math.floor(elapsed)} 秒</span>}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={expanded ? "aq-rotated" : ""}
|
||||
@@ -63,6 +69,7 @@ export function ThinkingState({
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
id={traceId}
|
||||
className="aq-trace-collapse"
|
||||
aria-hidden={!expanded}
|
||||
style={{
|
||||
@@ -72,14 +79,17 @@ export function ThinkingState({
|
||||
>
|
||||
<div className="aq-trace-clip">
|
||||
<ol className="aq-trace">
|
||||
{!steps.length && <li><span>{working ? "正在连接分析服务…" : "暂无执行步骤"}</span></li>}
|
||||
{steps.map((step, index) => (
|
||||
<li key={`${index}-${step}`}>
|
||||
<li key={`${index}-${step}`} data-current={index === steps.length - 1}>
|
||||
{working && index === steps.length - 1 ? (
|
||||
<LoaderCircle
|
||||
className="aq-spin"
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : index === steps.length - 1 && (failed || stopped) ? (
|
||||
<StatusIcon size={14} aria-hidden="true" />
|
||||
) : (
|
||||
<Check size={14} aria-hidden="true" />
|
||||
)}
|
||||
|
||||
@@ -405,9 +405,6 @@
|
||||
color: var(--aq-muted);
|
||||
margin-top: 24px;
|
||||
}
|
||||
.aq-mobile-break {
|
||||
display: none;
|
||||
}
|
||||
.aq-turns {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
@@ -1124,9 +1121,6 @@
|
||||
.aq-welcome > p {
|
||||
font-size: 12px;
|
||||
}
|
||||
.aq-mobile-break {
|
||||
display: block;
|
||||
}
|
||||
.aq-welcome-mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
@@ -2014,3 +2008,93 @@
|
||||
.aq-generated-metrics strong { font-size: 21px; }
|
||||
.aq-stacked-section { padding: 7px; }
|
||||
}
|
||||
|
||||
/* The full-page assistant owns the mobile viewport, including keyboard resize. */
|
||||
@media (max-width: 767px) {
|
||||
.app-shell:has(.aq-page) .app-mobile-nav { display: none; }
|
||||
.app-shell:has(.aq-page) .app-shell-content { padding-bottom: 0; }
|
||||
.aq-shell.aq-page {
|
||||
position: fixed;
|
||||
top: var(--aq-viewport-top, 0px);
|
||||
left: 0;
|
||||
height: var(--aq-viewport-height, 100dvh);
|
||||
max-height: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
.aq-page .aq-sidebar { display: none; }
|
||||
.aq-page .aq-composer-area { flex-shrink: 0; }
|
||||
.aq-page .aq-composer textarea { font-size: 16px; }
|
||||
}
|
||||
|
||||
/* Quiet, expandable execution traces inspired by Beautiful UI. */
|
||||
.aq-thinking {
|
||||
border: 1px solid var(--aq-line);
|
||||
border-radius: 12px;
|
||||
background: var(--aq-bg);
|
||||
padding: 2px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.aq-thinking-working { background: var(--aq-sidebar); }
|
||||
.aq-thinking > button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
.aq-thinking > button > svg { flex-shrink: 0; }
|
||||
.aq-thinking-title { min-width: 0; overflow-wrap: anywhere; }
|
||||
.aq-thinking-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.aq-thinking .aq-time { font-size: 12px; opacity: 1; }
|
||||
.aq-trace { margin: 0 0 12px 7px; padding: 0 0 0 18px; }
|
||||
.aq-trace li { align-items: flex-start; gap: 10px; padding: 7px 0; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.aq-trace li svg { flex-shrink: 0; margin-top: 3px; }
|
||||
.aq-thinking-working .aq-trace li[data-current="true"] { color: var(--aq-ink); }
|
||||
.aq-thinking[data-state="failed"] > button > svg { color: var(--aq-danger); }
|
||||
.aq-question p { padding: 12px 16px; border-radius: 18px 18px 4px 18px; font-weight: 400; }
|
||||
.aq-assistant-label { margin-bottom: 10px; }
|
||||
.aq-assistant-label strong { font-size: 13px; }
|
||||
.aq-assistant-label > span:last-child { font-size: 12px; }
|
||||
.aq-composer { border-radius: 18px; box-shadow: 0 2px 8px #00000006; }
|
||||
.aq-composer:focus-within { border-color: var(--aq-accent); box-shadow: 0 0 0 3px var(--aq-soft); }
|
||||
.aq-welcome h2 { text-wrap: balance; }
|
||||
.aq-prompt-grid > button { transition: background 160ms ease, border-color 160ms ease; }
|
||||
.aq-prompt-grid > button:hover { background: var(--aq-sidebar); border-color: var(--aq-accent); }
|
||||
.aq-page button:focus-visible { outline: 2px solid var(--aq-accent); outline-offset: 3px; }
|
||||
.aq-page button:active:not(:disabled) { opacity: .75; }
|
||||
@media (max-width: 767px) {
|
||||
.aq-header { min-height: 64px; height: auto; padding: 8px 12px; }
|
||||
.aq-header > div { gap: 4px; }
|
||||
.aq-header .aq-icon-button { width: 44px; height: 44px; min-width: 44px; }
|
||||
.aq-thinking { margin-left: 0; padding: 2px 10px; }
|
||||
.aq-thinking-meta { gap: 6px; }
|
||||
.aq-question p { max-width: 88%; font-size: 15px; }
|
||||
.aq-welcome { padding: 24px 16px 16px; }
|
||||
.aq-welcome-mark { display: none; }
|
||||
.aq-welcome h2 { margin: 0 0 8px; font-size: 22px; line-height: 1.4; letter-spacing: -.5px; }
|
||||
.aq-welcome > p { margin: 0; font-size: 13px; line-height: 1.5; }
|
||||
.aq-welcome-detail { display: none; }
|
||||
.aq-prompt-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 20px; gap: 8px; }
|
||||
.aq-prompt-grid > button { flex-direction: row; align-items: center; justify-content: flex-start; gap: 8px; min-width: 0; min-height: 48px; padding: 12px; border-radius: 12px; }
|
||||
.aq-prompt-grid > button > div { flex-shrink: 0; margin: 0; color: var(--aq-ink); }
|
||||
.aq-prompt-grid > button > div > svg:last-child { display: none; }
|
||||
.aq-prompt-grid > button strong { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.aq-prompt-grid > button > span { display: none; }
|
||||
.aq-welcome-note { margin-top: 12px; font-size: 12px; line-height: 1.5; flex-wrap: nowrap; }
|
||||
.aq-welcome-note svg { flex-shrink: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aq-page .aq-spin, .aq-page .aq-shimmer, .aq-page .aq-trace li { animation: none; }
|
||||
.aq-page .aq-shimmer { background: none; color: var(--aq-muted); }
|
||||
.aq-page .aq-trace-collapse { transition: none; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ResultView } from "./ResultView";
|
||||
import type { AssistantResult } from "../../../shared/assets/assistant";
|
||||
|
||||
test("count answer renders prose without report cards and only offers records for positive counts", () => {
|
||||
for (const count of [0, 7]) {
|
||||
const result: AssistantResult = {
|
||||
plan: {kind:"assets",category:"abnormal",groupBy:"none",view:"chart",filters:{}},
|
||||
conciseCount:true,outcome:"complete",summary:`当前异动车辆为 ${count} 台。`,
|
||||
title:"异动车辆概览",metrics:[{label:"异动车辆",value:count,unit:"台"}],
|
||||
rows:[],groups:[],source:"测试台账",fetchedAt:"2026-09-23T00:00:00Z",suggestions:["查看全部车辆"],
|
||||
};
|
||||
const noop = () => {};
|
||||
const html = renderToStaticMarkup(createElement(ResultView,{result,ask:noop,operate:noop,restore:noop,active:true,activate:noop,subject:null,disabled:false,showCorrection:noop}));
|
||||
assert.match(html,new RegExp(`当前异动车辆为 ${count} 台`));
|
||||
assert.doesNotMatch(html,/aq-primary-metrics|aq-result-card|aq-followups|aq-query-heading/);
|
||||
assert.equal(html.includes("查看车辆明细"),count > 0);
|
||||
assert.equal(html.includes("导出明细"),count > 0);
|
||||
assert.match(html,/统计口径、权限与数据时间/);
|
||||
}
|
||||
});
|
||||
@@ -2,8 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Upload, Zap } from 'lucide-react';
|
||||
import { fetchJson } from '../../auth/api-client';
|
||||
import { useAuth } from '../../auth/useAuth';
|
||||
import FeedbackFab from '../../components/FeedbackFab';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { PageFrame } from '../../components/ui/surface';
|
||||
import { BatchTable } from './import-page/BatchTable';
|
||||
import { RecordFilters } from './import-page/RecordFilters';
|
||||
@@ -168,8 +166,7 @@ export default function EleImportPage() {
|
||||
<RecordTable items={items} />
|
||||
</section>
|
||||
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
<FeedbackFab module="ele" />
|
||||
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BadgeCheck, CarFront, FileText, ReceiptText, WalletCards } from 'lucide-react';
|
||||
import { fetchEtcOverview, type EtcOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
function formatYuan(value: number) {
|
||||
@@ -27,7 +26,6 @@ export default function ETCView() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<EmptyState title="暂无 ETC 通行台账" description="当前账单和通行明细均未入库,接入后会自动展示统计数据。" />
|
||||
<RotatingFooterHint hints={['ETC 台账状态已按数据库实时核对', '待通行明细入库后自动生成统计']} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +44,6 @@ export default function ETCView() {
|
||||
<div>当前统计直接读取 ETC 通行明细与账单台账;金额、应收和已收均不做推算。</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AnimatePresence, motion } from 'motion/react';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchElectricMonthly } from './api';
|
||||
import type { CustomerType, DateQuickPick, ElectricMonthGroup } from './types';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile } from '../../components/ui/surface';
|
||||
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||
import {
|
||||
@@ -184,7 +183,6 @@ export default function ElectricDaily() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RotatingFooterHint />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
|
||||
import { BatteryCharging, Gauge, Wallet, CalendarClock } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
function fmtYuan(yuan: number) {
|
||||
@@ -126,7 +125,7 @@ export default function ElectricOverview() {
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</SurfaceCard>
|
||||
<RotatingFooterHint />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import MonitoringView from './MonitoringView';
|
||||
import StatisticsView from './StatisticsView';
|
||||
import DailyReportView from './DailyReportView';
|
||||
import { useHashSubTab } from '../energy/useHashSubTab';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
|
||||
|
||||
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
|
||||
@@ -45,7 +44,7 @@ export default function MileageModule() {
|
||||
)}
|
||||
</FadeIn>
|
||||
</AnimatePresence>
|
||||
<RotatingFooterHint />
|
||||
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import SuggestionList from './SuggestionList';
|
||||
import SuggestionDetail from './SuggestionDetail';
|
||||
import NotificationHistory from './NotificationHistory';
|
||||
import { exportSuggestionsCsv } from './csv-export';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { PageFrame } from '../../components/ui/surface';
|
||||
import ActiveFilterTags from './scheduling-module/ActiveFilterTags';
|
||||
import BatchActionBar from './scheduling-module/BatchActionBar';
|
||||
@@ -309,7 +308,7 @@ export default function SchedulingModule() {
|
||||
onSubmit={handleBatchSubmit}
|
||||
/>
|
||||
)}
|
||||
<RotatingFooterHint className="pb-4" />
|
||||
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -257,3 +257,15 @@ test("metric references keep one unit when the model appends it in prose", async
|
||||
await generatePresentation(value,"说明库存",signal());
|
||||
assert.equal(value.presentation?.blocks[0].text,"库存2 台,其中待交车0台。");
|
||||
});
|
||||
|
||||
test("verified single count bypasses generated report and external request", async (t) => {
|
||||
configure(t);
|
||||
t.mock.method(globalThis, "fetch", async () => { throw new Error("count must not call presentation model"); });
|
||||
const r = result({plan:plan({category:"abnormal",groupBy:"none"}),outcome:"empty",rows:[],groups:[],metrics:[{label:"异动车辆",value:0,unit:"台"}]});
|
||||
await generatePresentation(r,"当前有多少异动车辆?",new AbortController().signal);
|
||||
assert.equal(r.conciseCount,true);
|
||||
assert.equal(r.outcome,"complete");
|
||||
assert.equal(r.presentation,undefined);
|
||||
assert.equal(r.presentationWarning,undefined);
|
||||
assert.match(r.summary,/异动车辆为 0 台/);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ResultPresentation,
|
||||
} from "../../../shared/assets/assistant.js";
|
||||
import { validatePlan } from "./model.js";
|
||||
import { simplifyCountAnswer } from "./presentation.js";
|
||||
|
||||
type Dataset = "groups" | "rows" | "daily" | "comparison";
|
||||
type Field = { field: string; label: string; type: "number" | "string" | "boolean" };
|
||||
@@ -158,6 +159,8 @@ function fallback(target: AssistantResult, warning: string) {
|
||||
}
|
||||
/** Generate presentation metadata only; query data and authorization are unchanged. */
|
||||
export async function generatePresentation(result: AssistantResult, question: string, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) throw signal.reason || new Error("已取消");
|
||||
if (simplifyCountAnswer(result, question)) return;
|
||||
if (process.env.ASSET_AI_EXTERNAL_ENABLED === "0") return;
|
||||
if (signal.aborted) throw signal.reason || new Error("已取消");
|
||||
const targets = targetsFor(result).filter(target => target.result.plan.responseMode !== "vehicle-status");
|
||||
|
||||
@@ -158,3 +158,11 @@ test("presentation receives authorized aggregates while vehicle rows stay in the
|
||||
);
|
||||
assert.equal(events.filter((e) => e.type === "step").length, 4);
|
||||
});
|
||||
|
||||
test("inventory subsets do not inherit parent inventory metric cards", () => {
|
||||
for (const category of ["abnormal", "pending", "available", "awaiting"] as const) {
|
||||
const r = assetResult(vehicles, {kind:"assets", category, groupBy:"none", view:"chart", filters:{}});
|
||||
assert.equal(r.metrics.length, 1, category);
|
||||
assert.doesNotMatch(r.summary, /其中异动.*待交车/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -335,7 +335,7 @@ export function assetResult(
|
||||
missingDueDate: "未填写预计完成时间",
|
||||
}[plan.exception || "overdue"];
|
||||
let summary = rows.length
|
||||
? `${scope}共有 ${rows.length.toLocaleString()} 台${CATEGORY_LABELS[plan.category]}${plan.exception ? `,均为${exceptionLabel}的异动车辆` : ""}。${abnormal || pending ? `其中异动 ${abnormal} 台、待交车 ${pending} 台,已计入库存。` : ""}${plan.topN ? `下方展示排名前 ${plan.topN} 的分组,总量与占比仍按完整筛选范围计算。` : ""}`
|
||||
? `${scope}共有 ${rows.length.toLocaleString()} 台${CATEGORY_LABELS[plan.category]}${plan.exception ? `,均为${exceptionLabel}的异动车辆` : ""}。${["all", "inventory"].includes(plan.category) && (abnormal || pending) ? `其中异动 ${abnormal} 台、待交车 ${pending} 台,已计入库存。` : ""}${plan.topN ? `下方展示排名前 ${plan.topN} 的分组,总量与占比仍按完整筛选范围计算。` : ""}`
|
||||
: `${scope}的${CATEGORY_LABELS[plan.category]}为 0 台,暂无符合条件车辆。`;
|
||||
let inventoryBreakdown: AssistantResult["inventoryBreakdown"];
|
||||
if (plan.category === "all") summary = `${scope}共有资产 ${rows.length.toLocaleString()} 台,其中运营 ${operating.toLocaleString()} 台、库存 ${inventory.toLocaleString()} 台,运营率 ${operationRate}%。`;
|
||||
@@ -405,7 +405,7 @@ export function assetResult(
|
||||
unit: "%",
|
||||
},
|
||||
]
|
||||
: [
|
||||
: plan.category === "inventory" ? [
|
||||
{
|
||||
label: "其中可运营",
|
||||
value: selected.filter((v) =>
|
||||
@@ -420,9 +420,9 @@ export function assetResult(
|
||||
).length,
|
||||
unit: "台",
|
||||
},
|
||||
]),
|
||||
] : []),
|
||||
...(plan.analysis === "briefing" ? [{ label: "其中可运营库存", value: selected.filter(v => isInventoryVehicle(v) && ["3", "可运营"].includes(v.operationStatus || "")).length, unit: "台" }, { label: "其中待运营库存", value: selected.filter(v => isInventoryVehicle(v) && ["4", "待运营"].includes(v.operationStatus || "")).length, unit: "台" }] : []),
|
||||
...(!["all", "operating"].includes(plan.category) || plan.analysis === "briefing"
|
||||
...(plan.category === "inventory" || plan.analysis === "briefing"
|
||||
? [
|
||||
{ label: "其中异动", value: abnormal, unit: "台" },
|
||||
{ label: "其中待交车", value: pending, unit: "台" },
|
||||
|
||||
@@ -23,3 +23,30 @@ test("opening inventory from a briefing leaves briefing mode and keeps inherited
|
||||
const next=applyAction(briefing,{type:"category",category:"inventory"},result(briefing));
|
||||
assert.equal(next.category,"inventory");assert.equal(next.analysis,"distribution");assert.deepEqual(next.filters,briefing.filters);
|
||||
});
|
||||
|
||||
test("simple counts answer directly, including verified zero, while preserving scope", async () => {
|
||||
const { simplifyCountAnswer } = await import("./presentation.js");
|
||||
for (const count of [0, 7]) {
|
||||
const r = result({...plan, category:"abnormal", groupBy:"none", view:"chart"});
|
||||
r.outcome = count ? "complete" : "empty";
|
||||
r.metrics = [{label:"异动车辆",value:count,unit:"台"},{label:"其中待交车",value:0,unit:"台"}];
|
||||
r.scope = resultScope(r.plan,"测试公司");
|
||||
assert.equal(simplifyCountAnswer(r,"当前有多少异动车辆?"),true);
|
||||
assert.match(r.summary,new RegExp(`异动车辆为 ${count} 台`));
|
||||
assert.match(r.summary,/测试公司.*4.5T.*嘉兴市/);
|
||||
assert.equal(r.metrics.length,1);
|
||||
assert.equal(r.outcome,"complete");
|
||||
assert.deepEqual(r.suggestions,[]);
|
||||
}
|
||||
});
|
||||
|
||||
test("counts never collapse analysis requests, missing metrics or incomplete results", async () => {
|
||||
const { simplifyCountAnswer } = await import("./presentation.js");
|
||||
const make = () => ({...result({...plan,category:"abnormal",groupBy:"none",view:"chart"}), outcome:"complete" as const,metrics:[{label:"异动车辆",value:7,unit:"台"}]});
|
||||
for (const question of ["各区域有多少异动车辆", "异动和待交车分别多少", "异动有多少,占比多少", "有多少异动车辆,查看明细", "分析异动车辆数量变化"]) {
|
||||
assert.equal(simplifyCountAnswer(make(),question),false,question);
|
||||
}
|
||||
for (const override of [{outcome:"partial" as const},{warning:"源数据未接入"},{metrics:[]},{plan:{...make().plan,view:"list" as const}},{plan:{...make().plan,groupBy:"city" as const}}]) {
|
||||
assert.equal(simplifyCountAnswer({...make(),...override},"当前有多少异动车辆?"),false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
import { CATEGORY_LABELS, GROUP_LABELS, type AssistantResult, type QueryPlan } from "../../../shared/assets/assistant.js";
|
||||
|
||||
/** Only simplify verified, single-scope count queries; never infer zero from missing data. */
|
||||
export function simplifyCountAnswer(result: AssistantResult, question: string): boolean {
|
||||
const p = result.plan;
|
||||
const metric = result.metrics.find(m => m.label === CATEGORY_LABELS[p.category] && m.unit === "台");
|
||||
if (result.sections?.length || p.kind !== "assets" || p.responseMode ||
|
||||
p.groupBy !== "none" || p.secondaryGroupBy || p.view !== "chart" ||
|
||||
(p.analysis && p.analysis !== "distribution") || p.comparePrevious || p.topN ||
|
||||
result.warning || result.unanswered?.length || result.gap ||
|
||||
!["complete", "empty"].includes(result.outcome || "") ||
|
||||
!metric || !Number.isFinite(metric.value) || metric.value < 0 ||
|
||||
!/(多少|几台|几辆|数量|总数)/.test(question) ||
|
||||
/(分别|各|分布|占比|比例|运营率|对比|比较|趋势|变化|原因|为什么|分析|明细|清单|列表|表格|图表|排行|最多|最少|以及|和|与|、)/.test(question)) return false;
|
||||
const scope = result.scope || resultScope(p, null);
|
||||
const filters = scope.filters.slice(1);
|
||||
result.conciseCount = true;
|
||||
result.summary = `${scope.company}${filters.length ? `,${filters.join(",")}` : ""},当前${CATEGORY_LABELS[p.category]}为 ${metric.value.toLocaleString()} 台。`;
|
||||
result.metrics = [metric];
|
||||
result.suggestions = [];
|
||||
// A successful zero count is an answer, not a query failure or missing dataset.
|
||||
result.outcome = "complete";
|
||||
delete result.presentation;
|
||||
delete result.presentationStatus;
|
||||
delete result.presentationWarning;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resultScope(plan: QueryPlan, subject: string | null): NonNullable<AssistantResult["scope"]> {
|
||||
const filters = [
|
||||
plan.kind === "flow" ? "交退换车业务" : CATEGORY_LABELS[plan.category],
|
||||
|
||||
@@ -167,6 +167,7 @@ export type DemandGap = {
|
||||
};
|
||||
export type AssistantResult = {
|
||||
plan: QueryPlan;
|
||||
conciseCount?: boolean;
|
||||
presentation?: ResultPresentation;
|
||||
presentationStatus?: "generated" | "fallback";
|
||||
presentationWarning?: string;
|
||||
|
||||
Reference in New Issue
Block a user