Commit d946dfc8 by luoqi

feat(web): 助手工具卡隐藏改 loading 文案 + 日历 shadcn 中文化修翻页 + 回访时分下拉

- assistant:工具调用不再渲染卡片,运行中的工具以"正在xxx…"喂给下方 loading 指示(替换"生成中"),
  空档默认"生成中…";英文工具名彻底不露出。
- calendar:react-day-picker 换官方 shadcn Tailwind classNames(teal 主题);中文化(日一二…/2026年7月,
  周日起始);修 month_caption 盖住翻页箭头导致点不动(z-10 + pointer-events)。
- outcome-form:下次回访时间由预设下拉改 时(00-23)+分(00-59)两下拉,每分钟粒度,默认 00:00。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 19278f18
Pipeline #3377 failed in 0 seconds
...@@ -8,7 +8,6 @@ import { ...@@ -8,7 +8,6 @@ import {
Bot, Bot,
Check, Check,
ChevronDown, ChevronDown,
ChevronRight,
LayoutTemplate, LayoutTemplate,
Loader2, Loader2,
Maximize2, Maximize2,
...@@ -16,7 +15,6 @@ import { ...@@ -16,7 +15,6 @@ import {
Send, Send,
Sparkles, Sparkles,
Square, Square,
Wrench,
X, X,
ExternalLink, ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
...@@ -34,7 +32,6 @@ import { ...@@ -34,7 +32,6 @@ import {
type Artifact, type Artifact,
type Block, type Block,
type ChatMessage, type ChatMessage,
type ToolStep,
} from './use-assistant-chat'; } from './use-assistant-chat';
const MODELS = [ const MODELS = [
...@@ -48,95 +45,19 @@ const EXAMPLES = [ ...@@ -48,95 +45,19 @@ const EXAMPLES = [
'现在有哪些应治未治、值得召回的患者?', '现在有哪些应治未治、值得召回的患者?',
]; ];
// ── 工具结果格式化 + 轻量摘要 ────────────────────────────── // ── 工具中文元信息:助手用的是内部英文工具名,界面不展示工具卡,只把"正在做什么"
function pretty(v: unknown): string { // 喂给下方 loading 指示(见 MessageView)。未知工具兜底用原名。
if (v == null) return ''; const TOOL_META: Record<string, { label: string; running: string }> = {
if (typeof v === 'string') { find_patient: { label: '查找患者', running: '正在查找患者' },
try { get_patient_overview: { label: '读取患者概览', running: '正在读取患者概览' },
return JSON.stringify(JSON.parse(v), null, 2); get_persona: { label: '读取患者画像', running: '正在读取患者画像' },
} catch { get_facts: { label: '读取关键事实', running: '正在读取关键事实' },
return v; get_recall_plan: { label: '读取召回计划', running: '正在读取召回计划' },
} list_recall_queue: { label: '检索召回队列', running: '正在检索召回队列' },
} recall_queue_stats: { label: '统计召回池', running: '正在统计召回池' },
return JSON.stringify(v, null, 2); };
} function toolMeta(tool: string) {
function resultSummary(result: unknown): string { return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` };
if (result == null) return '';
let v: unknown = result;
if (typeof result === 'string') {
try {
v = JSON.parse(result);
} catch {
return '已返回';
}
}
if (Array.isArray(v)) return `返回 ${v.length} 项`;
if (v && typeof v === 'object') {
const o = v as Record<string, unknown>;
if (Array.isArray(o.items)) return `返回 ${o.items.length} 条`;
if (o.recallPlan !== undefined || o.persona !== undefined) return '已返回患者数据';
return '已返回数据';
}
return '已返回';
}
// ── 工具调用折叠卡(对齐设计稿)─────────────────────────────
function ToolCallView({ step }: { step: ToolStep }) {
const [open, setOpen] = useState(false);
return (
<div className="overflow-hidden rounded-lg border border-slate-100 bg-slate-50/70">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-slate-100/60"
>
<ChevronRight
className={cn('h-3.5 w-3.5 flex-none text-slate-400 transition-transform', open && 'rotate-90')}
/>
<Wrench className="h-3.5 w-3.5 flex-none text-slate-400" />
<span className="flex-none text-[12px] text-slate-500">调用工具</span>
<code className="flex-none rounded border border-slate-100 bg-white px-1.5 py-0.5 font-mono text-[11.5px] text-slate-700">
{step.tool}
</code>
<span className="ml-1 truncate text-[11px] text-slate-400">
{step.status === 'done' ? resultSummary(step.result) : ''}
</span>
<span className="ml-auto flex-none">
{step.status === 'running' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-slate-400" />
) : step.status === 'error' ? (
<span className="text-[11px] text-rose-600">出错</span>
) : (
<Check className="h-3.5 w-3.5 text-emerald-600" />
)}
</span>
</button>
{open && (
<div className="space-y-2 border-t border-slate-100/70 px-3 pb-3 pt-1">
<CodeBlock label="入参" code={pretty(step.args) || '{}'} />
{step.result !== undefined && <CodeBlock label="返回" code={pretty(step.result)} muted />}
</div>
)}
</div>
);
}
function CodeBlock({ label, code, muted }: { label: string; code: string; muted?: boolean }) {
return (
<div>
<div className="mb-1 mt-1.5 text-[10.5px] font-medium uppercase tracking-wide text-slate-400">
{label}
</div>
<pre
className={cn(
'overflow-x-auto rounded-md border border-slate-100 bg-white px-3 py-2 text-[11.5px] leading-relaxed text-slate-600',
muted && 'max-h-44 overflow-y-auto',
)}
>
<code>{code}</code>
</pre>
</div>
);
} }
// ── LLM 文本 → markdown 渲染(用户要求:不复用设计的结构卡,改 markdown 解析)── // ── LLM 文本 → markdown 渲染(用户要求:不复用设计的结构卡,改 markdown 解析)──
...@@ -197,7 +118,8 @@ function Markdown({ text }: { text: string }) { ...@@ -197,7 +118,8 @@ function Markdown({ text }: { text: string }) {
} }
function BlockView({ block }: { block: Block }) { function BlockView({ block }: { block: Block }) {
if (block.kind === 'tool') return <ToolCallView step={block.step} />; // 工具调用不再单独显示卡片 —— 其"正在做什么"通过下方 loading 指示体现(见 MessageView)。
if (block.kind === 'tool') return null;
if (block.kind === 'artifact') return <ArtifactView artifact={block.artifact} />; if (block.kind === 'artifact') return <ArtifactView artifact={block.artifact} />;
return <Markdown text={block.text} />; return <Markdown text={block.text} />;
} }
...@@ -396,12 +318,13 @@ function ArtifactView({ artifact }: { artifact: Artifact }) { ...@@ -396,12 +318,13 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
); );
} }
// 生成中指示:覆盖"工具已返回但下一段(文字/artifact)还在整段生成"的空档。 // 生成中指示:覆盖"工具运行 / 工具已返回但下一段还在生成"的空档。
function GeneratingHint() { // 有工具在跑 → 显示"正在xxx…"(动作);否则默认"生成中…"。
function GeneratingHint({ label = '生成中' }: { label?: string }) {
return ( return (
<div className="flex items-center gap-1.5 text-[12px] text-slate-400"> <div className="flex items-center gap-1.5 text-[12px] text-slate-400">
<Loader2 className="h-3.5 w-3.5 animate-spin text-teal-500" /> <Loader2 className="h-3.5 w-3.5 animate-spin text-teal-500" />
<span>生成中</span> <span>{label}</span>
</div> </div>
); );
} }
...@@ -416,9 +339,12 @@ function MessageView({ message, streaming }: { message: ChatMessage; streaming?: ...@@ -416,9 +339,12 @@ function MessageView({ message, streaming }: { message: ChatMessage; streaming?:
</div> </div>
); );
} }
// 流式中:若最后一块不是正在增长的文字(即在调工具/生成 artifact 的空档),补一个"生成中"。 // 流式中:若最后一块不是正在增长的文字(即在调工具/生成 artifact 的空档),补一个 loading 指示。
// 若最后一块是正在跑的工具 → 显示"正在xxx…"(动作);否则默认"生成中…"。
const last = message.blocks[message.blocks.length - 1]; const last = message.blocks[message.blocks.length - 1];
const showHint = streaming && (!last || last.kind !== 'text'); const showHint = streaming && (!last || last.kind !== 'text');
const hintLabel =
last && last.kind === 'tool' && last.step.status === 'running' ? toolMeta(last.step.tool).running : '生成中';
return ( return (
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<span className="mt-0.5 inline-flex h-7 w-7 flex-none items-center justify-center rounded-full bg-teal-600 text-white"> <span className="mt-0.5 inline-flex h-7 w-7 flex-none items-center justify-center rounded-full bg-teal-600 text-white">
...@@ -428,7 +354,7 @@ function MessageView({ message, streaming }: { message: ChatMessage; streaming?: ...@@ -428,7 +354,7 @@ function MessageView({ message, streaming }: { message: ChatMessage; streaming?:
{message.blocks.map((b, i) => ( {message.blocks.map((b, i) => (
<BlockView key={i} block={b} /> <BlockView key={i} block={b} />
))} ))}
{showHint && <GeneratingHint />} {showHint && <GeneratingHint label={hintLabel} />}
</div> </div>
</div> </div>
); );
......
...@@ -10,7 +10,17 @@ import { ...@@ -10,7 +10,17 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { tone } from './shared'; import { tone } from './shared';
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
import { Input } from '@/components/ui/input'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
// 回访时间:时(00–23)+ 分(00–59)两个下拉,每分钟粒度,默认 00:00
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
/// 本地日期 ⇄ 'yyyy-MM-dd' 字符串(避免 new Date(ISO) 的时区偏移) /// 本地日期 ⇄ 'yyyy-MM-dd' 字符串(避免 new Date(ISO) 的时区偏移)
const toYMD = (d: Date) => const toYMD = (d: Date) =>
...@@ -104,9 +114,14 @@ export function OutcomeForm({ ...@@ -104,9 +114,14 @@ export function OutcomeForm({
// 终态(已结案/已放弃/已被替代)不可再写 execution(后端也会拒);提交按钮置灰 + 顶部提示 // 终态(已结案/已放弃/已被替代)不可再写 execution(后端也会拒);提交按钮置灰 + 顶部提示
const isTerminal = ['completed', 'abandoned', 'superseded'].includes(plan.status ?? ''); const isTerminal = ['completed', 'abandoned', 'superseded'].includes(plan.status ?? '');
const canSubmit = !!outcome && !!channel && !submitted && !isTerminal; const canSubmit = !!outcome && !!channel && !submitted && !isTerminal;
// 下次回访时间拆成 日期('yyyy-MM-dd')+ 时间('HH:mm')两部分,日历选日期、时间框选时间,合成回 scheduledNextAt // 下次回访时间拆成 日期('yyyy-MM-dd')+ 时间('HH:mm')两部分,日历选日期、时/分下拉选时间,合成回 scheduledNextAt
const schedDate = scheduledNextAt.split('T')[0] ?? ''; const schedDate = scheduledNextAt.split('T')[0] ?? '';
const schedTime = scheduledNextAt.split('T')[1] ?? ''; const schedTime = scheduledNextAt.split('T')[1] ?? '';
const [schedHour = '', schedMin = ''] = schedTime.split(':');
// 时/分任一变更后合成回时间(缺省补 00),需已选日期
const setTime = (h: string, m: string) => {
if (schedDate) setScheduledNextAt(`${schedDate}T${(h || '00').padStart(2, '0')}:${(m || '00').padStart(2, '0')}`);
};
const handleSubmit = () => { const handleSubmit = () => {
if (!canSubmit) return; if (!canSubmit) return;
...@@ -215,19 +230,40 @@ export function OutcomeForm({ ...@@ -215,19 +230,40 @@ export function OutcomeForm({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<DatePicker <DatePicker
value={schedDate ? fromYMD(schedDate) : undefined} value={schedDate ? fromYMD(schedDate) : undefined}
// 选日期时,时间缺省给 10:00(没填时间也能合成有效 datetime) // 选日期时,时间缺省给 00:00(默认 0 时 0 分)
onChange={(d) => setScheduledNextAt(d ? `${toYMD(d)}T${schedTime || '10:00'}` : '')} onChange={(d) => setScheduledNextAt(d ? `${toYMD(d)}T${schedTime || '00:00'}` : '')}
min={new Date(new Date().setHours(0, 0, 0, 0))} min={new Date(new Date().setHours(0, 0, 0, 0))}
placeholder="选择回访日期" placeholder="选择回访日期"
/> />
</div> </div>
<Input {/* 时 / 分 两个下拉,每分钟粒度,默认 00:00;下拉内可键入数字快速定位 */}
type="time" <div className="flex flex-none items-center gap-0.5">
value={schedTime} <Select value={schedHour || undefined} disabled={!schedDate} onValueChange={(h) => setTime(h, schedMin)}>
disabled={!schedDate} <SelectTrigger className="h-9 w-[58px] bg-white text-[12.5px]">
onChange={(e) => schedDate && setScheduledNextAt(`${schedDate}T${e.target.value}`)} <SelectValue placeholder="时" />
className="w-[96px] flex-none text-[12.5px] appearance-none bg-white [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" </SelectTrigger>
/> <SelectContent className="max-h-64 min-w-0">
{HOURS.map((h) => (
<SelectItem key={h} value={h} className="text-[12.5px]">
{h}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[12.5px] text-amber-900">:</span>
<Select value={schedMin || undefined} disabled={!schedDate} onValueChange={(m) => setTime(schedHour, m)}>
<SelectTrigger className="h-9 w-[58px] bg-white text-[12.5px]">
<SelectValue placeholder="分" />
</SelectTrigger>
<SelectContent className="max-h-64 min-w-0">
{MINUTES.map((m) => (
<SelectItem key={m} value={m} className="text-[12.5px]">
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div> </div>
</div> </div>
)} )}
......
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
import { DayPicker } from 'react-day-picker'; import { DayPicker } from 'react-day-picker';
import 'react-day-picker/style.css'; import { zhCN } from 'date-fns/locale';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { buttonVariants } from './button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>; export type CalendarProps = React.ComponentProps<typeof DayPicker>;
/// shadcn 风格日历(react-day-picker v10)。teal 主题靠覆写 rdp 内置 CSS 变量, const WEEKDAYS_ZH = ['日', '一', '二', '三', '四', '五', '六'];
/// 透传所有 DayPicker props(mode / captionLayout / disabled / startMonth / endMonth …)。 // 中文格式化:星期"日一二…"、标题"2026年7月"、下拉"7月"/"2026年"。
export function Calendar({ className, ...props }: CalendarProps) { const zhFormatters = {
formatWeekdayName: (d: Date) => WEEKDAYS_ZH[d.getDay()] ?? '',
formatCaption: (d: Date) => `${d.getFullYear()}${d.getMonth() + 1}月`,
formatMonthDropdown: (d: Date) => `${d.getMonth() + 1}月`,
formatYearDropdown: (d: Date) => `${d.getFullYear()}年`,
};
/// shadcn 官方 Calendar(react-day-picker v10)—— 全 Tailwind classNames,不引 rdp 默认样式表。
/// 主题走项目 CSS 变量(--primary=teal-600 / --accent / --ring),故选中/今天/焦点自动是 teal。
/// 支持 captionLayout="dropdown"(月/年下拉)与默认 label(左右翻页箭头)两种 caption。
export function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return ( return (
<DayPicker <DayPicker
showOutsideDays showOutsideDays={showOutsideDays}
className={cn('p-2', className)} locale={zhCN}
style={ weekStartsOn={0}
{ formatters={zhFormatters}
'--rdp-accent-color': '#0d9488', className={cn('relative p-3', className)}
'--rdp-accent-background-color': '#f0fdfa', classNames={{
'--rdp-day-width': '2rem', months: 'flex flex-col sm:flex-row gap-2',
'--rdp-day-height': '2rem', month: 'relative flex flex-col gap-4',
'--rdp-day_button-width': '2rem', month_caption: 'flex justify-center pt-1 items-center h-9',
'--rdp-day_button-height': '2rem', caption_label: 'text-sm font-medium',
'--rdp-font-family': 'inherit', // 月/年下拉(captionLayout="dropdown")
fontSize: '12.5px', dropdowns: 'flex items-center justify-center gap-1.5 text-sm font-medium',
} as React.CSSProperties dropdown_root:
} 'relative inline-flex items-center gap-1 rounded-md border border-input px-2 py-1 hover:bg-accent focus-within:ring-2 focus-within:ring-ring',
dropdown: 'absolute inset-0 h-full w-full cursor-pointer opacity-0',
// 左右翻页箭头:nav 绝对定位横跨日历宽度、两端对齐,箭头落在 caption 两侧(锚到 relative root)。
// z-10 抬到 month_caption 之上(否则被居中的 caption 盖住导致点不动);容器 pointer-events-none
// 让空白中段的点击穿透到下方的月/年下拉,按钮各自 pointer-events-auto 可点。
nav: 'pointer-events-none absolute inset-x-3 top-3 z-10 flex items-center justify-between',
button_previous: cn(
buttonVariants({ variant: 'outline' }),
'pointer-events-auto size-7 bg-transparent p-0 opacity-60 hover:opacity-100',
),
button_next: cn(
buttonVariants({ variant: 'outline' }),
'pointer-events-auto size-7 bg-transparent p-0 opacity-60 hover:opacity-100',
),
// 日期网格
month_grid: 'w-full border-collapse',
weekdays: 'flex',
weekday: 'w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground',
week: 'mt-1 flex w-full',
day: 'relative size-8 p-0 text-center text-sm focus-within:relative focus-within:z-20',
day_button: cn(
buttonVariants({ variant: 'ghost' }),
'size-8 rounded-md p-0 font-normal aria-selected:opacity-100',
),
// 状态修饰(targeting 内层 button)
selected:
'[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground',
today: '[&>button]:bg-accent [&>button]:text-accent-foreground',
outside: 'text-muted-foreground opacity-50',
disabled: 'text-muted-foreground opacity-40',
hidden: 'invisible',
...classNames,
}}
components={{
Chevron: ({ orientation, className: c, ...rest }) => {
const Icon =
orientation === 'left' ? ChevronLeft : orientation === 'right' ? ChevronRight : ChevronDown;
return <Icon className={cn('size-4', c)} {...rest} />;
},
}}
{...props} {...props}
/> />
); );
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment