diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index d83ceec..e94c0e9 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -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 ( -
+
{/* 氢费看板按原型交付;其他模块继续保留全局水印。 */} {!isHydrogenPrototype ? (
@@ -166,7 +165,7 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) { {/* 内容区 */} -
+
{!isHydrogenPrototype && (
羚牛氢能 BI @@ -177,11 +176,10 @@ export function Shell({ modules }: { modules: ModuleConfig[] }) {
}> {ActiveComponent && } - {!isHydrogenPrototype && activeModule !== 'assets' ? : null}
{/* 移动端底部导航 (md 以下) */} -
- + )}
@@ -873,7 +878,7 @@ function ResultViewContent({

来源:{result.source}

- {plan.kind !== "clarify" && ( + {plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
- {!!suggestions.length && ( + {!result.conciseCount && !!suggestions.length && (
{plan.kind === "clarify" ? "你可以这样继续" : "继续分析"} {suggestions.slice(0, 4).map((question) => ( diff --git a/src/modules/assets/assistant/ThinkingState.tsx b/src/modules/assets/assistant/ThinkingState.tsx index c6c7660..bb7e1ef 100644 --- a/src/modules/assets/assistant/ThinkingState.tsx +++ b/src/modules/assets/assistant/ThinkingState.tsx @@ -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(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 ( -
+
    + {!steps.length &&
  1. {working ? "正在连接分析服务…" : "暂无执行步骤"}
  2. } {steps.map((step, index) => ( -
  3. +
  4. {working && index === steps.length - 1 ? (
-
); } diff --git a/src/modules/energy/ElectricDaily.tsx b/src/modules/energy/ElectricDaily.tsx index 945662d..ac5f6f5 100644 --- a/src/modules/energy/ElectricDaily.tsx +++ b/src/modules/energy/ElectricDaily.tsx @@ -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() { ); })}
-
); } diff --git a/src/modules/energy/ElectricOverview.tsx b/src/modules/energy/ElectricOverview.tsx index 7fc2799..f9414b1 100644 --- a/src/modules/energy/ElectricOverview.tsx +++ b/src/modules/energy/ElectricOverview.tsx @@ -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() { - +
); } diff --git a/src/modules/mileage/MileageModule.tsx b/src/modules/mileage/MileageModule.tsx index 9466e5f..9bd6005 100644 --- a/src/modules/mileage/MileageModule.tsx +++ b/src/modules/mileage/MileageModule.tsx @@ -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() { )} - + ); } diff --git a/src/modules/scheduling/SchedulingModule.tsx b/src/modules/scheduling/SchedulingModule.tsx index 9dc88a8..a88eaa4 100644 --- a/src/modules/scheduling/SchedulingModule.tsx +++ b/src/modules/scheduling/SchedulingModule.tsx @@ -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} /> )} - + ); } diff --git a/src/server/routes/asset-assistant/generative.test.ts b/src/server/routes/asset-assistant/generative.test.ts index 8607e6c..cd75e0f 100644 --- a/src/server/routes/asset-assistant/generative.test.ts +++ b/src/server/routes/asset-assistant/generative.test.ts @@ -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 台/); +}); diff --git a/src/server/routes/asset-assistant/generative.ts b/src/server/routes/asset-assistant/generative.ts index ae9ba7f..759be51 100644 --- a/src/server/routes/asset-assistant/generative.ts +++ b/src/server/routes/asset-assistant/generative.ts @@ -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 { + 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"); diff --git a/src/server/routes/asset-assistant/model.test.ts b/src/server/routes/asset-assistant/model.test.ts index fe3961d..31cf7c9 100644 --- a/src/server/routes/asset-assistant/model.test.ts +++ b/src/server/routes/asset-assistant/model.test.ts @@ -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, /其中异动.*待交车/); + } +}); diff --git a/src/server/routes/asset-assistant/model.ts b/src/server/routes/asset-assistant/model.ts index 63d3595..bc52a1d 100644 --- a/src/server/routes/asset-assistant/model.ts +++ b/src/server/routes/asset-assistant/model.ts @@ -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: "台" }, diff --git a/src/server/routes/asset-assistant/presentation.test.ts b/src/server/routes/asset-assistant/presentation.test.ts index a836682..ae39d34 100644 --- a/src/server/routes/asset-assistant/presentation.test.ts +++ b/src/server/routes/asset-assistant/presentation.test.ts @@ -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); + } +}); diff --git a/src/server/routes/asset-assistant/presentation.ts b/src/server/routes/asset-assistant/presentation.ts index 3978c27..ef2d797 100644 --- a/src/server/routes/asset-assistant/presentation.ts +++ b/src/server/routes/asset-assistant/presentation.ts @@ -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 { const filters = [ plan.kind === "flow" ? "交退换车业务" : CATEGORY_LABELS[plan.category], diff --git a/src/shared/assets/assistant.ts b/src/shared/assets/assistant.ts index c8c182a..89e7e28 100644 --- a/src/shared/assets/assistant.ts +++ b/src/shared/assets/assistant.ts @@ -167,6 +167,7 @@ export type DemandGap = { }; export type AssistantResult = { plan: QueryPlan; + conciseCount?: boolean; presentation?: ResultPresentation; presentationStatus?: "generated" | "fallback"; presentationWarning?: string;