feat(ui): refine gem-blue acrylic palette and focus borders

Derive the gem-blue palette with OKLCH tonal and complementary-color
rules while preserving acrylic opacity, blur and surface hierarchy.

Darker focus tokens exposed stacked field rings, fallback outlines and
persistent basemap selection rings. Keep fallback focus styles in the
base layer and use one themed field boundary or inset card stroke.
Preserve keyboard focus, invalid states and native input semantics.

Add browser regression coverage for pointer and keyboard focus, all
basemap presentations, narrow layouts and rendered contrast.

Validation in an isolated checkout: pnpm lint, pnpm typecheck,
pnpm test (393 tests), focused browser suites and pnpm build.
This commit is contained in:
2026-10-09 10:57:06 +08:00
parent d2d705ed4c
commit 617ba4d87c
69 changed files with 1239 additions and 765 deletions
+41 -7
View File
@@ -4,19 +4,36 @@
## 1. 视觉主题 ## 1. 视觉主题
方向为浅色专业调度台。界面保持高信息密度,但使用稳定的标题、状态、操作、证据顺序,减少连续嵌套的彩色卡片。视觉参考取 Linear 的状态聚焦、Grafana 的数据对齐,以及 ArcGIS Experience Builder 的地图优先侧栏。 方向为宝石蓝操作、银灰亚克力、少量香槟重点的浅色专业调度台。界面保持高信息密度,使用稳定的标题、状态、操作、证据顺序。沿用既有字体、布局、圆角和 acrylic 材质,以透底、色调和轻微背景色差区分导航、面板、分组与阅读区。
## 2. 色彩角色 ## 2. 色彩角色
- `--surface-reading`:主要阅读面。 采用同色相色阶与低彩度互补色规则。宝石蓝 `#4263C7` 转换为 OKLCH 后约为 `L=0.529317, C=0.160186, h=267.059148°`;中性材质和文字保持该色相,香槟辅助色使用 `(h + 180°) mod 360°`。色阶先计算后转换为 sRGB HEX,均处于 sRGB 色域。透明度不参与色板生成。
- `slate-50`:同一业务区内的次级数据面。
- `--action-blue`:主操作、焦点和调查任务状态。 | 角色 | L | C | h | HEX |
|---|---:|---:|---:|---|
| 宝石蓝主色 | 0.529317 | 0.160186 | 267.06° | `#4263C7` |
| 银霜材质 | 0.935 | 0.012 | 267.06° | `#E6E9F2` |
| 冷瓷阅读面 | 0.975 | 0.005 | 267.06° | `#F5F7FA` |
| 香槟辅助色 | 0.820 | 0.065 | 87.06° | `#D6C294` |
| 深墨文字 | 0.280 | 0.025 | 267.06° | `#232935` |
完整色板见 [宝石蓝配色](gem-blue-palette.svg)。
- `--surface-navigation`:宝石蓝 8% 与银霜在 OKLab 空间混合的导航色调。浅色混色使用 OKLab,避免低彩度颜色在色相插值时偏色。
- `--surface-panel`:银霜色调;`--surface-well` 为其半透明次级面。
- `--surface-subtle`:`L=0.950, C=0.010, h=267.06°`,得到 `#EBEEF5`,供桌面 dock、分组和普通控件使用。
- `--surface-reading`:冷瓷正文、表单和报告阅读面,保持较高遮蔽度。
- `--action-selection-soft`:`L=0.920, C=0.035, h=267.06°`,得到 `#DAE5FD`,文字使用深宝石蓝。
- `--action-blue`:主操作 `#4263C7`;hover 将 L 降低 0.055、C 乘 0.95,得到 `#3653B0`;pressed 将 L 降低 0.105、C 乘 0.90,得到 `#2B469C`。
- `--surface-border` / `--surface-divider`:保持同色相,分别使用 `(L=0.815, C=0.015)` 和 `(L=0.885, C=0.012)`,得到银灰边界 `#BEC2CD` / `#D5D9E1`。
- `--action-focus-ring`:深宝石蓝焦点环;`--ink-muted`:`L=0.450, C=0.030, h=267.06°`,得到辅助文字 `#4E5566`。
- `cyan-700`:外围样板入口及其来源标识。 - `cyan-700`:外围样板入口及其来源标识。
- `emerald`:已完成、已通过。 - `emerald`:已完成、已通过。
- `amber`:需要确认、暂停、结果未知。 - `--status-warning-soft`:香槟色 25% 与冷瓷混合的确认、暂停和结果未知提示底色;同互补色相的文字使用 `(L=0.430, C=0.070)`,得到 `#604D1D`;状态标记使用 `(L=0.600, C=0.090)`,得到 `#977D3C`。
- `rose`:失败、冲突或破坏性操作。 - `rose`:失败、冲突或破坏性操作。
彩色区域只表达语义。普通分组使用白色阅读面、灰色边界和轻微背景层级。 宝石蓝用于操作和选中状态,银灰用于材质层级。香槟色只用于确认、警告与 Agent 欢迎标记的小面积点缀,不作为普通面板的整块底色。成功、失败、地图业务图层和数据曲线保留各自语义色。
## 3. 排版规则 ## 3. 排版规则
@@ -36,7 +53,18 @@
## 6. 层级与深度 ## 6. 层级与深度
侧栏使用既有 acrylic 容器。一级业务区使用不透明阅读面和 1px 边界。区内次级数据使用背景色差,不叠加投影。弹层可使用较强投影,普通卡片只保留极轻的 1px 投影。 保持既有亚克力半透明材质,在透底、模糊、噪点和边缘高光上调整色调。桌面 Agent dock 延续既有实底布局,使用银灰色调。
| 材质 | 浅色底图 alpha | 影像底图 alpha |
|---|---:|---:|
| 导航 | 0.64 | 0.80 |
| 浮动业务面板 | 0.56 | 0.72 |
| 地图控件 | 0.74 | 0.86 |
| Agent 移动抽屉和历史 | 0.82 | 0.88 |
| Agent 浮动输入区和运行摘要 | 0.86 | 0.90 |
| 正文阅读面 | 0.96 | 0.985 |
分组标题及次级数据使用银灰色差,不叠加投影。模糊仍为面板 24px、控件及 Agent 浮层 18px。地图和影像中的底色应能透过外层材质;报告正文保留更高遮蔽度,保证阅读。
## 7. 使用约束 ## 7. 使用约束
@@ -53,6 +81,12 @@
支持 1280px 桌面和 320px、375px 窄屏。所有交互控件保留可见焦点,触控目标至少 40px。弹层受视口高度和宽度限制。hover 仅在支持 hover 的设备启用,按压使用轻微缩放,并尊重 reduced motion。 支持 1280px 桌面和 320px、375px 窄屏。所有交互控件保留可见焦点,触控目标至少 40px。弹层受视口高度和宽度限制。hover 仅在支持 hover 的设备启用,按压使用轻微缩放,并尊重 reduced motion。
表单字段使用共享 `.form-field` 规则:聚焦时只改变原有 1px 边框的颜色,不叠加外环或浏览器默认轮廓。组合输入由最外层字段容器承担焦点边界,内部输入保持无边框;无边框的亚克力输入区使用 1px 内描边,保持材质与布局不变。无效字段保留错误色,按钮与选择控件保留单独的键盘焦点提示。全局焦点样式仅作为基础层兜底,不能覆盖组件自身规则。
底图选择卡片的选中态使用 1px 主题边框和勾选标记,不附加常驻外环。键盘聚焦使用共享的 1px 内描边;鼠标选中、聚焦与按压均保持卡片尺寸及半透明表面不变。图层面板、独立底图面板与浮动底图控件使用同一规则。
普通文字与实际背景对比度至少 4.5:1,焦点和必要的非文字状态标记至少 3:1。透明区域按合成背景计算。不支持模糊或降低透明度时保留不透明的银霜、冷瓷层级;强制颜色模式使用系统颜色。主题由现有 Tailwind 与 CSS 变量统一提供,不新增切换入口。
## 9. 交互提示 ## 9. 交互提示
任务面板先显示任务状态和下一步,再提供计划、目标、运行与证据。运行暂停时突出检查点校验或恢复中的一个主操作。外围面板先显示连接状态、历史回放和待提交来源,测试资源与原始请求回执默认按需展开。 任务面板先显示任务状态和下一步,再提供计划、目标、运行与证据。运行暂停时突出检查点校验或恢复中的一个主操作。外围面板先显示连接状态、历史回放和待提交来源,测试资源与原始请求回执默认按需展开。
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="490" viewBox="0 0 1120 490">
<rect width="1120" height="490" fill="#F5F7FA"/>
<g font-family="Noto Sans SC Variable,Noto Sans CJK SC,Microsoft YaHei,sans-serif" fill="#232935">
<text x="32" y="46" font-size="24" font-weight="600">宝石蓝 · 银灰亚克力</text>
<text x="32" y="76" font-size="14" fill="#4E5566">同色相色阶 + 低彩度互补色 · 色板为不透明基准,亚克力透明度独立设置</text>
<rect x="32" y="102" width="192" height="236" rx="8" fill="#4263C7" stroke="#BEC2CD"/>
<text x="32" y="373" font-size="20" font-family="Consolas,monospace">#4263C7</text>
<text x="32" y="402" font-size="16">宝石蓝 · 主操作 · 品牌</text>
<text x="32" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.529 C 0.160 h 267.06</text>
<rect x="248" y="102" width="192" height="236" rx="8" fill="#E6E9F2" stroke="#BEC2CD"/>
<text x="248" y="373" font-size="20" font-family="Consolas,monospace">#E6E9F2</text>
<text x="248" y="402" font-size="16">银霜 · 亚克力材质</text>
<text x="248" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.935 C 0.012 h 267.06</text>
<rect x="464" y="102" width="192" height="236" rx="8" fill="#F5F7FA" stroke="#BEC2CD"/>
<text x="464" y="373" font-size="20" font-family="Consolas,monospace">#F5F7FA</text>
<text x="464" y="402" font-size="16">冷瓷 · 阅读 · 表单</text>
<text x="464" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.975 C 0.005 h 267.06</text>
<rect x="680" y="102" width="192" height="236" rx="8" fill="#D6C294" stroke="#BEC2CD"/>
<text x="680" y="373" font-size="20" font-family="Consolas,monospace">#D6C294</text>
<text x="680" y="402" font-size="16">香槟 · 互补暖色点缀</text>
<text x="680" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.820 C 0.065 h 87.06</text>
<rect x="896" y="102" width="192" height="236" rx="8" fill="#232935" stroke="#BEC2CD"/>
<text x="896" y="373" font-size="20" font-family="Consolas,monospace">#232935</text>
<text x="896" y="402" font-size="16">深墨 · 标题 · 正文</text>
<text x="896" y="430" font-size="12" fill="#4E5566" font-family="Consolas,monospace">L 0.280 C 0.025 h 267.06</text>
<text x="32" y="468" font-size="13" fill="#4E5566">主色 h = 267.06°;同色色阶保持 h;香槟色 h = (267.06° + 180°) mod 360° = 87.06°</text>
</g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+2 -1
View File
@@ -1,3 +1,4 @@
import { readThemeColor } from "../../tests/browser/support/theme-colors";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockAgentApi } from "../../tests/browser/support/mock-agent-api"; import { mockAgentApi } from "../../tests/browser/support/mock-agent-api";
import { mockWorkbenchContext } from "../../tests/browser/support/mock-workbench-context"; import { mockWorkbenchContext } from "../../tests/browser/support/mock-workbench-context";
@@ -137,7 +138,7 @@ test("keeps menu origin and the solid Main Frame styling through Tailwind v4", a
}; };
}); });
expect(navigationMaterial.backdropFilter).toBe("none"); expect(navigationMaterial.backdropFilter).toBe("none");
expect(navigationMaterial.backgroundColor).toBe("oklch(0.965 0.012 248)"); expect(navigationMaterial.backgroundColor).toBe(await readThemeColor(navigation, "--agent-dock-surface"));
await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default"); await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default");
await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0); await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0);
}); });
@@ -28,7 +28,7 @@ export function AgentCollapsedRail({
aria-label={expandLabel} aria-label={expandLabel}
title={expandLabel} title={expandLabel}
onClick={onExpand} onClick={onExpand}
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border hover:bg-white" className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border hover:bg-[var(--surface-reading)]"
> >
<AgentPersona className="h-11 w-11" state={personaState} /> <AgentPersona className="h-11 w-11" state={personaState} />
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5"> <span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
@@ -373,7 +373,7 @@ export function AgentCommandPanel({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500"> <p className="shrink-0 text-xs font-semibold uppercase text-[var(--ink-muted)]">
对话主题 对话主题
</p> </p>
<StatusBadge <StatusBadge
@@ -587,8 +587,8 @@ export function AgentCommandPanel({
className={cn( className={cn(
"agent-panel-band relative", "agent-panel-band relative",
floatingPresentation floatingPresentation
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1" ? "form-field agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
: "border-t border-slate-200 p-3" : "border-t border-[var(--surface-divider)] p-3"
)} )}
> >
{!hasConversation ? ( {!hasConversation ? (
@@ -608,7 +608,7 @@ export function AgentCommandPanel({
key={suggestion} key={suggestion}
suggestion={suggestion} suggestion={suggestion}
onClick={submitPrompt} onClick={submitPrompt}
className="surface-reading max-w-full border border-slate-200 text-slate-600 hover:bg-blue-50 hover:text-blue-700" className="surface-reading max-w-full border border-[var(--surface-divider)] text-slate-600 hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]"
> >
<span className="truncate">{suggestion}</span> <span className="truncate">{suggestion}</span>
</Suggestion> </Suggestion>
@@ -617,7 +617,7 @@ export function AgentCommandPanel({
</div> </div>
) : null} ) : null}
<PromptInput <PromptInput
className="agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:ring-0!" className="form-field agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none"
onSubmit={(message) => { onSubmit={(message) => {
const nextPrompt = message.text.trim(); const nextPrompt = message.text.trim();
if (nextPrompt) { if (nextPrompt) {
@@ -638,7 +638,7 @@ export function AgentCommandPanel({
</PromptInputBody> </PromptInputBody>
<PromptInputFooter className="justify-between gap-2"> <PromptInputFooter className="justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{!qaOnly ? <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-slate-500">任务协作问答</span>} {!qaOnly ? <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-[var(--ink-muted)]">任务协作问答</span>}
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<AgentModelSelect <AgentModelSelect
@@ -712,7 +712,7 @@ function VoiceInputButton({
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']", "group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
"border-transparent bg-transparent shadow-none", "border-transparent bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200", "transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1", "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-45", "disabled:cursor-not-allowed disabled:opacity-45",
isListening isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95" ? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
@@ -822,7 +822,7 @@ function ApprovalModeSelect({
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-slate-50" className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-[var(--surface-reading)]"
disabled={disabled || !onValueChange} disabled={disabled || !onValueChange}
aria-label="权限批准模式" aria-label="权限批准模式"
> >
@@ -842,28 +842,28 @@ function ApprovalModeSelect({
sideOffset={8} sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[220px] rounded-xl p-2 shadow-lg" className="agent-panel-overlay agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
> >
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500"> <DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
权限批准模式 权限批准模式
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
className={cn( className={cn(
"items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50", "items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-[var(--surface-reading)]",
requestApproval && "border-blue-200 bg-blue-50 focus:bg-blue-50" requestApproval && "border-[var(--surface-border)] bg-[var(--action-soft)] focus:bg-[var(--action-soft)]"
)} )}
onSelect={() => onValueChange?.("request")} onSelect={() => onValueChange?.("request")}
> >
<Shield className="mt-0.5 text-blue-600" aria-hidden="true" /> <Shield className="mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span> <span>
<span className="block text-sm font-semibold text-slate-800">请求批准</span> <span className="block text-sm font-semibold text-slate-800">请求批准</span>
<span className="block text-xs leading-5 text-slate-500">工具权限逐次确认</span> <span className="block text-xs leading-5 text-[var(--ink-muted)]">工具权限逐次确认</span>
</span> </span>
{requestApproval ? ( {requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-blue-600" aria-hidden="true" /> <CheckCircle2 className="ml-auto mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
) : null} ) : null}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className={cn( className={cn(
"mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50", "mt-1 items-start rounded-lg border border-transparent px-2.5 py-2 focus:bg-[var(--surface-reading)]",
!requestApproval && !requestApproval &&
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)] focus:bg-[var(--status-soft)]" "status-tone-success border-[var(--status-border)] bg-[var(--status-soft)] focus:bg-[var(--status-soft)]"
)} )}
@@ -875,7 +875,7 @@ function ApprovalModeSelect({
/> />
<span> <span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span> <span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-slate-500">自动允许本轮权限请求</span> <span className="block text-xs leading-5 text-[var(--ink-muted)]">自动允许本轮权限请求</span>
</span> </span>
{!requestApproval ? ( {!requestApproval ? (
<CheckCircle2 <CheckCircle2
@@ -993,7 +993,7 @@ function AgentModelSelect({
if (!models.length) { if (!models.length) {
return ( return (
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">模型</span> <span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-[var(--ink-muted)]">模型</span>
); );
} }
@@ -1004,7 +1004,7 @@ function AgentModelSelect({
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="w-12 shrink-0 justify-between gap-1 border-transparent bg-transparent px-1 text-xs text-slate-600 shadow-none hover:bg-slate-50 sm:w-24 sm:gap-1.5 sm:px-1.5" className="w-12 shrink-0 justify-between gap-1 border-transparent bg-transparent px-1 text-xs text-slate-600 shadow-none hover:bg-[var(--surface-reading)] sm:w-24 sm:gap-1.5 sm:px-1.5"
disabled={!onValueChange} disabled={!onValueChange}
aria-label="选择 Agent 模型" aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`} aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
@@ -1024,7 +1024,7 @@ function AgentModelSelect({
sideOffset={8} sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[230px] rounded-xl p-2 shadow-lg" className="agent-panel-overlay agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
> >
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500"> <DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
模型选择 模型选择
</DropdownMenuLabel> </DropdownMenuLabel>
<div className="space-y-1"> <div className="space-y-1">
@@ -1034,10 +1034,10 @@ function AgentModelSelect({
<DropdownMenuItem <DropdownMenuItem
key={model.id} key={model.id}
className={cn( className={cn(
"rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50", "rounded-lg border border-transparent px-2.5 py-2 focus:bg-[var(--surface-reading)]",
selected && (presentation === "desktop-dock" selected && (presentation === "desktop-dock"
? "border-blue-200 bg-blue-50 focus:bg-blue-50" ? "border-[var(--surface-border)] bg-[var(--action-soft)] focus:bg-[var(--action-soft)]"
: "border-violet-200 bg-violet-50 focus:bg-violet-50") : "border-[var(--surface-border)] bg-[var(--action-soft)] focus:bg-[var(--action-soft)]")
)} )}
onSelect={() => onValueChange?.(model.id)} onSelect={() => onValueChange?.(model.id)}
> >
@@ -1048,12 +1048,12 @@ function AgentModelSelect({
<span className="block truncate text-sm font-semibold text-slate-800"> <span className="block truncate text-sm font-semibold text-slate-800">
{model.label} {model.label}
</span> </span>
<span className="block truncate text-xs leading-4 text-slate-500"> <span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
{model.description} {model.description}
</span> </span>
</span> </span>
{selected ? ( {selected ? (
<CheckCircle2 size={15} className={presentation === "desktop-dock" ? "text-blue-600" : "text-violet-600"} aria-hidden="true" /> <CheckCircle2 size={15} className={presentation === "desktop-dock" ? "text-[var(--action-blue-hover)]" : "text-[var(--action-blue-hover)]"} aria-hidden="true" />
) : null} ) : null}
</DropdownMenuItem> </DropdownMenuItem>
); );
@@ -1099,7 +1099,7 @@ function ExpertModelIcon({ size }: { size: number }) {
height={size} height={size}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
className="text-violet-600" className="text-[var(--action-blue-hover)]"
aria-hidden="true" aria-hidden="true"
> >
<path <path
@@ -1116,21 +1116,21 @@ function AgentReadyMark() {
<g className="agent-ready-orbit"> <g className="agent-ready-orbit">
<path <path
d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z" d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z"
fill="#CE75FF" fill="var(--palette-brand)"
/> />
<path <path
d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z" d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z"
fill="#F62E76" fill="var(--palette-champagne)"
/> />
<path <path
d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z" d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z"
fill="#CD88FF" fill="var(--palette-sand)"
/> />
</g> </g>
<path <path
className="agent-ready-core" className="agent-ready-core"
d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z" d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z"
fill="#F3E2FF" fill="var(--action-selection-soft)"
/> />
</svg> </svg>
); );
@@ -1200,8 +1200,8 @@ function AgentEmptyState({ compact = false }: { compact?: boolean }) {
key={label} key={label}
className={cn( className={cn(
"agent-capability flex min-h-20 items-center gap-3 px-3.5 py-3 text-left", "agent-capability flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-slate-300/80", index % 2 === 0 && "border-r border-[var(--surface-border)]/80",
index < 2 && "border-b border-slate-300/80" index < 2 && "border-b border-[var(--surface-border)]/80"
)} )}
> >
<span className="agent-capability-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]"> <span className="agent-capability-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
@@ -1312,7 +1312,7 @@ function BackendMessageList({
<p className="whitespace-pre-wrap">{message.content}</p> <p className="whitespace-pre-wrap">{message.content}</p>
) )
) : streaming && index === messages.length - 1 ? ( ) : streaming && index === messages.length - 1 ? (
<span className="text-slate-500">正在生成...</span> <span className="text-[var(--ink-muted)]">正在生成...</span>
) : null} ) : null}
{message.role === "assistant" && !qaOnly ? ( {message.role === "assistant" && !qaOnly ? (
<AgentMessageDetails <AgentMessageDetails
@@ -1466,7 +1466,7 @@ function AgentSpeechMessage({
key="speech-selection-action" key="speech-selection-action"
type="button" type="button"
data-agent-presentation={presentation} data-agent-presentation={presentation}
className="agent-panel-overlay agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25" className="agent-panel-overlay agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ animate={{
opacity: 1, opacity: 1,
@@ -1480,7 +1480,7 @@ function AgentSpeechMessage({
onClick={speakFromSelection} onClick={speakFromSelection}
> >
<span <span
className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-blue-50/75 text-blue-600" className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-[var(--action-soft)]/75 text-[var(--action-blue-hover)]"
aria-hidden="true" aria-hidden="true"
> >
<Volume2 size={15} /> <Volume2 size={15} />
@@ -1495,7 +1495,7 @@ function AgentSpeechMessage({
: null} : null}
{isSupported && !disabled ? ( {isSupported && !disabled ? (
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-slate-100 pt-1.5"> <div className="mt-2 flex min-h-8 items-center gap-1 border-t border-[var(--surface-divider)] pt-1.5">
{speechState === "idle" ? ( {speechState === "idle" ? (
<SpeechIconButton <SpeechIconButton
label="语音播放" label="语音播放"
@@ -1507,7 +1507,7 @@ function AgentSpeechMessage({
{speechState === "loading" ? ( {speechState === "loading" ? (
<> <>
<SpeechIconButton label="正在生成语音" disabled> <SpeechIconButton label="正在生成语音" disabled>
<LoaderCircle size={15} className="animate-spin text-blue-600" aria-hidden="true" /> <LoaderCircle size={15} className="animate-spin text-[var(--action-blue-hover)]" aria-hidden="true" />
</SpeechIconButton> </SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}> <SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={14} fill="currentColor" aria-hidden="true" /> <Square size={14} fill="currentColor" aria-hidden="true" />
@@ -1534,7 +1534,7 @@ function AgentSpeechMessage({
</SpeechIconButton> </SpeechIconButton>
</> </>
) : null} ) : null}
<span className="text-[11px] text-slate-400"> <span className="text-[11px] text-[var(--ink-muted)]">
{speechState === "idle" {speechState === "idle"
? "语音播放" ? "语音播放"
: speechState === "loading" : speechState === "loading"
@@ -1572,8 +1572,8 @@ function SpeechIconButton({
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
className={cn( className={cn(
"shrink-0 text-slate-500 hover:bg-slate-100 disabled:cursor-wait", "shrink-0 text-[var(--ink-muted)] hover:bg-[var(--surface-control)] disabled:cursor-wait",
tone === "primary" && "text-blue-600", tone === "primary" && "text-[var(--action-blue-hover)]",
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]" tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
)} )}
> >
@@ -75,7 +75,7 @@ export function AgentHistoryPanel({
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-semibold text-slate-950">历史记录</p> <p className="text-sm font-semibold text-slate-950">历史记录</p>
<p className="mt-0.5 text-xs text-slate-500"> <p className="mt-0.5 text-xs text-[var(--ink-muted)]">
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"} {sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
</p> </p>
</div> </div>
@@ -103,7 +103,7 @@ export function AgentHistoryPanel({
{loading && sessions.length === 0 ? ( {loading && sessions.length === 0 ? (
<motion.div <motion.div
key="history-loading" key="history-loading"
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-slate-500" className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-[var(--ink-muted)]"
initial="initial" initial="initial"
animate="animate" animate="animate"
exit="exit" exit="exit"
@@ -128,7 +128,7 @@ export function AgentHistoryPanel({
className={cn( className={cn(
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors", "agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
active && !editing active && !editing
? "agent-history-item-active border border-blue-200" ? "agent-history-item-active border border-[var(--surface-border)]"
: "border border-transparent" : "border border-transparent"
)} )}
initial="initial" initial="initial"
@@ -153,7 +153,7 @@ export function AgentHistoryPanel({
> >
删除“{session.title}”? 删除“{session.title}”?
</span> </span>
<span className="mt-0.5 block truncate text-xs text-slate-500"> <span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
删除后无法从历史记录中恢复 删除后无法从历史记录中恢复
</span> </span>
</motion.div> </motion.div>
@@ -172,7 +172,7 @@ export function AgentHistoryPanel({
> >
<Input <Input
autoFocus autoFocus
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs" className="agent-history-rename-input h-8 rounded-lg bg-[var(--surface-reading)] text-xs"
value={draftTitle} value={draftTitle}
disabled={saving} disabled={saving}
onChange={(event) => setDraftTitle(event.currentTarget.value)} onChange={(event) => setDraftTitle(event.currentTarget.value)}
@@ -201,7 +201,7 @@ export function AgentHistoryPanel({
> >
{session.title} {session.title}
</span> </span>
<span className="mt-0.5 block truncate text-xs text-slate-500"> <span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
{formatSessionUpdatedAt(session.updatedAt)} {formatSessionUpdatedAt(session.updatedAt)}
</span> </span>
</motion.button> </motion.button>
@@ -214,7 +214,7 @@ export function AgentHistoryPanel({
{itemLoading || saving || deleting ? ( {itemLoading || saving || deleting ? (
<Loader2 <Loader2
size={13} size={13}
className="animate-spin text-blue-600" className="animate-spin text-[var(--action-blue-hover)]"
aria-hidden="true" aria-hidden="true"
/> />
) : null} ) : null}
@@ -289,7 +289,7 @@ export function AgentHistoryPanel({
title="删除对话" title="删除对话"
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
className="text-slate-500 hover:bg-red-50 hover:text-red-600" className="text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600"
disabled={itemLoading || deleting || !onDeleteSession} disabled={itemLoading || deleting || !onDeleteSession}
onClick={() => { onClick={() => {
cancelRename(); cancelRename();
@@ -307,7 +307,7 @@ export function AgentHistoryPanel({
) : ( ) : (
<motion.div <motion.div
key="history-empty" key="history-empty"
className="agent-history-state rounded-xl px-3 py-3 text-xs text-slate-500" className="agent-history-state rounded-xl px-3 py-3 text-xs text-[var(--ink-muted)]"
initial="initial" initial="initial"
animate="animate" animate="animate"
exit="exit" exit="exit"
@@ -13,6 +13,9 @@ import { AnimatePresence, motion } from "motion/react";
import { useId, useState, type ReactNode } from "react"; import { useId, useState, type ReactNode } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { Radio } from "@/shared/ui/radio";
import { Textarea } from "@/shared/ui/textarea";
import { import {
StatusBadge, StatusBadge,
StatusDot, StatusDot,
@@ -218,9 +221,9 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
return ( return (
<div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress> <div className="agent-panel-nested rounded-xl p-2.5" data-agent-progress>
<div className="flex min-h-8 items-center gap-2 px-0.5"> <div className="flex min-h-8 items-center gap-2 px-0.5">
<ListTree size={15} className="shrink-0 text-blue-600" aria-hidden="true" /> <ListTree size={15} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span> <span className="min-w-0 flex-1 truncate text-xs font-semibold text-slate-700">执行进度</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-slate-500"> <span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-[var(--ink-muted)]">
{running ? ( {running ? (
<StatusDot tone="info" activity="live" aria-hidden="true" /> <StatusDot tone="info" activity="live" aria-hidden="true" />
) : null} ) : null}
@@ -236,8 +239,8 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
<button <button
type="button" type="button"
className={cn( className={cn(
"-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-slate-400 transition-colors [@media(hover:hover)]:hover:text-blue-600", "-my-1 -mr-1.5 grid h-10 w-10 shrink-0 place-items-center rounded-md text-[var(--ink-muted)] transition-colors [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]",
expanded && "text-blue-500" expanded && "text-[var(--action-blue-hover)]"
)} )}
aria-controls={listId} aria-controls={listId}
aria-expanded={expanded} aria-expanded={expanded}
@@ -296,7 +299,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
{item.detail ? ( {item.detail ? (
<span <span
className={cn( className={cn(
"block leading-5 text-slate-500", "block leading-5 text-[var(--ink-muted)]",
compact ? "truncate" : "break-words" compact ? "truncate" : "break-words"
)} )}
title={item.detail} title={item.detail}
@@ -312,7 +315,7 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
className={cn( className={cn(
"min-w-12 pt-0.5 text-right text-xs font-semibold", "min-w-12 pt-0.5 text-right text-xs font-semibold",
item.status === "running" item.status === "running"
? "text-blue-600" ? "text-[var(--action-blue-hover)]"
: [ : [
`status-tone-${progressStatusMeta[item.status].tone}`, `status-tone-${progressStatusMeta[item.status].tone}`,
"text-[var(--status-foreground)]" "text-[var(--status-foreground)]"
@@ -429,7 +432,7 @@ function PermissionRequestCard({
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}> <p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
{getPermissionTitle(permission)} {getPermissionTitle(permission)}
</p> </p>
<p className="mt-0.5 truncate font-mono text-xs text-slate-500" title={target}> <p className="mt-0.5 truncate font-mono text-xs text-[var(--ink-muted)]" title={target}>
{target} {target}
</p> </p>
{permission.error ? ( {permission.error ? (
@@ -470,7 +473,7 @@ function PermissionRequestCard({
type="button" type="button"
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8 border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100" className="h-8 border-[var(--surface-border)] bg-[var(--action-soft)] px-2 text-xs text-[var(--action-blue-hover)] hover:bg-[var(--action-selection-soft)]"
disabled={disabled} disabled={disabled}
onClick={() => reply("always")} onClick={() => reply("always")}
> >
@@ -506,7 +509,7 @@ function QuestionList({
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void; onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) { }) {
return ( return (
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息" floating> <AgentNestedBlock icon={HelpCircle} iconClassName="text-[var(--action-blue-hover)]" title="补充信息" floating>
<motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate"> <motion.div className="flex flex-col gap-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence initial={false} mode="popLayout">
{questions.slice(-3).map((request) => ( {questions.slice(-3).map((request) => (
@@ -587,7 +590,7 @@ function QuestionRequestCard({
<p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p> <p className="status-tone-danger mt-2 line-clamp-2 text-xs leading-4 text-[var(--status-foreground)]">{request.error}</p>
) : null} ) : null}
{request.answers?.length ? ( {request.answers?.length ? (
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join(";")}> <p className="mt-2 truncate text-[var(--ink-muted)]" title={request.answers.map((answer) => answer.join("、")).join(";")}>
已答:{request.answers.map((answer) => answer.join("、")).join(";")} 已答:{request.answers.map((answer) => answer.join("、")).join(";")}
</p> </p>
) : null} ) : null}
@@ -617,7 +620,7 @@ function QuestionRequestCard({
type="button" type="button"
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8 border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50" className="h-8 border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs text-slate-600 hover:bg-[var(--surface-reading)]"
disabled={submitting || !onRejectQuestion} disabled={submitting || !onRejectQuestion}
onClick={reject} onClick={reject}
> >
@@ -686,21 +689,20 @@ function QuestionInput({
<div className="space-y-1"> <div className="space-y-1">
{question.options.map((option) => { {question.options.map((option) => {
const checked = value.selected.includes(option.label); const checked = value.selected.includes(option.label);
const Choice = question.multiple ? Checkbox : Radio;
return ( return (
<label <label
key={option.label} key={option.label}
className={cn( className={cn(
"flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors", "flex cursor-pointer items-start gap-2 rounded-md border px-2 py-1.5 transition-colors",
checked checked
? "border-blue-300 bg-blue-50/70 text-blue-700" ? "border-[var(--palette-brand)] bg-[var(--action-soft)]/70 text-[var(--action-blue-hover)]"
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70", : "border-[var(--surface-divider)]/80 text-slate-600 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)]/70",
disabled && "cursor-default opacity-60" disabled && "cursor-default opacity-60"
)} )}
> >
<input <Choice
type={question.multiple ? "checkbox" : "radio"}
name={name} name={name}
className="mt-0.5 h-3.5 w-3.5 accent-blue-600"
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)} onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
@@ -708,7 +710,7 @@ function QuestionInput({
<span className="min-w-0"> <span className="min-w-0">
<span className="block font-semibold text-slate-700">{option.label}</span> <span className="block font-semibold text-slate-700">{option.label}</span>
{option.description ? ( {option.description ? (
<span className="block leading-4 text-slate-500">{option.description}</span> <span className="block leading-4 text-[var(--ink-muted)]">{option.description}</span>
) : null} ) : null}
</span> </span>
</label> </label>
@@ -719,27 +721,24 @@ function QuestionInput({
className={cn( className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors", "flex cursor-pointer items-center gap-2 rounded-md border px-2 py-2 transition-colors",
value.customSelected value.customSelected
? "border-blue-300 bg-blue-50/70 text-blue-700" ? "border-[var(--palette-brand)] bg-[var(--action-soft)]/70 text-[var(--action-blue-hover)]"
: "border-slate-200/80 text-slate-600 hover:border-slate-300 hover:bg-slate-50/70", : "border-[var(--surface-divider)]/80 text-slate-600 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)]/70",
disabled && "cursor-default opacity-60" disabled && "cursor-default opacity-60"
)} )}
> >
<input {question.multiple ? (
type={question.multiple ? "checkbox" : "radio"} <Checkbox name={name} checked={value.customSelected} disabled={disabled} onChange={(event) => toggleCustom(event.currentTarget.checked)} />
name={name} ) : (
className="h-3.5 w-3.5 accent-blue-600" <Radio name={name} checked={value.customSelected} disabled={disabled} onChange={(event) => toggleCustom(event.currentTarget.checked)} />
checked={value.customSelected} )}
disabled={disabled}
onChange={(event) => toggleCustom(event.currentTarget.checked)}
/>
<span className="font-semibold text-slate-700">自定义回答</span> <span className="font-semibold text-slate-700">自定义回答</span>
</label> </label>
) : null} ) : null}
</div> </div>
) : null} ) : null}
{showCustomInput ? ( {showCustomInput ? (
<textarea <Textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-hidden transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60" className="min-h-16 resize-none border-[var(--surface-divider)]/80 px-2 py-1.5 text-xs leading-5 text-slate-700 shadow-none md:text-xs disabled:opacity-60"
aria-label="自定义回答内容" aria-label="自定义回答内容"
placeholder="输入自定义回答" placeholder="输入自定义回答"
value={value.custom} value={value.custom}
@@ -85,7 +85,7 @@ function AgentBriefMetric({
return ( return (
<div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2"> <div className="agent-brief-metric min-w-0 rounded-xl border px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600"> <dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-600">
<span className="text-blue-600">{icon}</span> <span className="text-[var(--action-blue-hover)]">{icon}</span>
{label} {label}
</dt> </dt>
<dd className="mt-1 truncate text-xs font-semibold text-slate-900" title={value}> <dd className="mt-1 truncate text-xs font-semibold text-slate-900" title={value}>
@@ -44,7 +44,7 @@ function useElementVisibility() {
function PersonaFallback() { function PersonaFallback() {
return ( return (
<span className="grid h-full w-full place-items-center rounded-full border border-slate-300/70 bg-slate-100 text-slate-500"> <span className="grid h-full w-full place-items-center rounded-full border border-[var(--surface-border)]/70 bg-[var(--surface-control)] text-[var(--ink-muted)]">
<Bot className="h-2/5 w-2/5" aria-hidden="true" /> <Bot className="h-2/5 w-2/5" aria-hidden="true" />
</span> </span>
); );
@@ -50,15 +50,15 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs"> <div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-xs">
<div className="mb-3 flex items-center justify-between gap-2"> <div className="mb-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700"> <span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]">
{getEnvelopeIcon(envelope)} {getEnvelopeIcon(envelope)}
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p> <p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p>
<p className="text-xs text-slate-500">{getSurfaceLabel(envelope.surface)}</p> <p className="text-xs text-[var(--ink-muted)]">{getSurfaceLabel(envelope.surface)}</p>
</div> </div>
</div> </div>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600"> <span className="shrink-0 rounded-full bg-[var(--surface-control)] px-2 py-1 text-xs font-semibold text-slate-600">
agent-ui@1 agent-ui@1
</span> </span>
</div> </div>
@@ -85,7 +85,7 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}> <p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
{spec.title ?? "Agent 图表"} {spec.title ?? "Agent 图表"}
</p> </p>
<p className="mt-1 text-xs text-slate-500"> <p className="mt-1 text-xs text-[var(--ink-muted)]">
{spec.chartType === "bar" ? "柱状图" : "折线图"} {spec.chartType === "bar" ? "柱状图" : "折线图"}
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""} {spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
</p> </p>
@@ -100,7 +100,7 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
</div> </div>
</div> </div>
<SafeChartSvg spec={spec} data={data} /> <SafeChartSvg spec={spec} data={data} />
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-slate-500">{spec.xAxisName}</p> : null} {spec.xAxisName ? <p className="mt-2 text-center text-xs text-[var(--ink-muted)]">{spec.xAxisName}</p> : null}
</div> </div>
); );
} }
@@ -302,7 +302,7 @@ function PropRows({ props, labels }: { props: unknown; labels: Record<string, st
<dl className="grid grid-cols-2 gap-2"> <dl className="grid grid-cols-2 gap-2">
{rows.map((row) => ( {rows.map((row) => (
<div key={row.label} className="surface-reading min-w-0 rounded-lg px-2.5 py-2"> <div key={row.label} className="surface-reading min-w-0 rounded-lg px-2.5 py-2">
<dt className="truncate text-xs text-slate-500">{row.label}</dt> <dt className="truncate text-xs text-[var(--ink-muted)]">{row.label}</dt>
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}> <dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
{row.value} {row.value}
</dd> </dd>
@@ -381,7 +381,7 @@ function chartStroke(index: number) {
} }
function chartColorClass(index: number) { function chartColorClass(index: number) {
return ["bg-blue-600", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-blue-600"; return ["bg-[var(--action-blue)]", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-[var(--action-blue)]";
} }
function formatChartNumber(value: number) { function formatChartNumber(value: number) {
@@ -57,7 +57,7 @@ export function MapAnnotationPanel({
/> />
)) ))
) : ( ) : (
<div className={cn("px-3 py-2 text-xs text-slate-500", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}> <div className={cn("px-3 py-2 text-xs text-[var(--ink-muted)]", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
{emptyText} {emptyText}
</div> </div>
)} )}
@@ -8,6 +8,7 @@ import {
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles"; } from "./map-control-styles";
@@ -85,12 +86,12 @@ export function BaseLayersControl({
)} )}
> >
<div className="flex items-center gap-2 px-1"> <div className="flex items-center gap-2 px-1">
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}> <span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-soft)] text-[var(--action-blue-hover)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Map size={17} aria-hidden="true" /> <Map size={17} aria-hidden="true" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2> <h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
<p className="mt-0.5 truncate text-xs text-slate-500"> <p className="mt-0.5 truncate text-xs text-[var(--ink-muted)]">
当前 {activeLayer?.label ?? "--"} 当前 {activeLayer?.label ?? "--"}
</p> </p>
</div> </div>
@@ -110,10 +111,11 @@ export function BaseLayersControl({
className={cn( className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150", "flex min-h-[76px] flex-col p-1 text-left transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active active
? "border-blue-500 text-blue-700 shadow-xs ring-2 ring-blue-100" ? "border-[var(--action-blue)] text-[var(--action-blue-hover)] shadow-xs"
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40", : "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40",
option.disabled && "cursor-not-allowed opacity-50" option.disabled && "cursor-not-allowed opacity-50"
)} )}
> >
@@ -163,8 +165,9 @@ export function BaseLayersControl({
className={cn( className={cn(
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150", "flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active ? "border-blue-500 shadow-xs ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40", active ? "border-[var(--action-blue)] shadow-xs" : "border-[var(--surface-divider)] hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40",
option.disabled && "cursor-not-allowed opacity-50" option.disabled && "cursor-not-allowed opacity-50"
)} )}
> >
@@ -184,11 +187,11 @@ export function BaseLayersControl({
type="button" type="button"
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`} aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
title={`${title}:当前 ${activeLayer?.label ?? ""}`} title={`${title}:当前 ${activeLayer?.label ?? ""}`}
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)} className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-[var(--palette-brand)] hover:text-[var(--action-blue-hover)]", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME, MAP_SELECTION_CARD_FOCUS_CLASS_NAME)}
> >
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}> <span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
{activeLayer ? ( {activeLayer ? (
<span className={cn("absolute inset-0", activeLayer.swatchClassName ?? "bg-slate-100")} /> <span className={cn("absolute inset-0", activeLayer.swatchClassName ?? "bg-[var(--surface-control)]")} />
) : ( ) : (
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)} )}
@@ -213,11 +216,11 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
className={cn( className={cn(
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner", "relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
option.swatchClassName ?? "bg-slate-100" option.swatchClassName ?? "bg-[var(--surface-control)]"
)} )}
> >
{active ? ( {active ? (
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-xs"> <span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-[var(--action-blue)] text-white shadow-xs">
<Check size={10} aria-hidden="true" /> <Check size={10} aria-hidden="true" />
</span> </span>
) : null} ) : null}
@@ -45,12 +45,12 @@ export function MapControlPanel({
)} )}
> >
<div className={cn("relative mb-3 flex items-center gap-2.5 px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}> <div className={cn("relative mb-3 flex items-center gap-2.5 px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-100 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}> <span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Icon size={17} aria-hidden="true" /> <Icon size={17} aria-hidden="true" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2> <h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
{description ? <p className="mt-0.5 truncate text-xs text-slate-500">{description}</p> : null} {description ? <p className="mt-0.5 truncate text-xs text-[var(--ink-muted)]">{description}</p> : null}
</div> </div>
</div> </div>
<div className={cn("relative space-y-3", contentClassName)}>{children}</div> <div className={cn("relative space-y-3", contentClassName)}>{children}</div>
@@ -61,7 +61,7 @@ export function MapControlPanel({
export function MapPanelSection({ title, children }: { title: string; children: ReactNode }) { export function MapPanelSection({ title, children }: { title: string; children: ReactNode }) {
return ( return (
<section> <section>
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">{title}</div> <div className="mb-1.5 px-1 text-xs font-semibold text-[var(--ink-muted)]">{title}</div>
<div className="space-y-1.5">{children}</div> <div className="space-y-1.5">{children}</div>
</section> </section>
); );
@@ -85,7 +85,7 @@ export function MapModeButton({ icon: Icon, label, active = false, disabled = fa
className={cn( className={cn(
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150", "flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
active ? "bg-blue-600 text-white" : "surface-control text-slate-600 hover:text-blue-700", active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600 hover:text-[var(--action-blue-hover)]",
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600" disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
)} )}
> >
@@ -112,7 +112,7 @@ export function MapActionChip({
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-blue-200 hover:text-blue-700", "flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-[var(--surface-border)] hover:text-[var(--action-blue-hover)]",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700" disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700"
@@ -163,17 +163,17 @@ export function MapActionRow({
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left", "flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
isPrimary isPrimary
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/25 hover:bg-[var(--action-blue-hover)]"
: strong : strong
? "border-blue-100 bg-blue-50/95 text-blue-800" ? "border-[var(--surface-border)] bg-[var(--action-soft)]/95 text-[var(--action-blue-pressed)]"
: "surface-control border-transparent text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700", : "surface-control border-transparent text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]",
isDanger && "hover:border-red-100 hover:text-red-700", isDanger && "hover:border-red-100 hover:text-red-700",
selected && !isPrimary && "border-blue-200 bg-blue-50 text-blue-800", selected && !isPrimary && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-pressed)]",
muted && "opacity-70", muted && "opacity-70",
disabled && disabled &&
(isPrimary (isPrimary
? "cursor-not-allowed opacity-55 hover:border-blue-600 hover:bg-blue-600 hover:text-white" ? "cursor-not-allowed opacity-55 hover:border-[var(--action-blue)] hover:bg-[var(--action-blue-hover)] hover:text-white"
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"), : "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"),
disabled && "shadow-none" disabled && "shadow-none"
)} )}
> >
@@ -182,10 +182,10 @@ export function MapActionRow({
"grid h-8 w-8 shrink-0 place-items-center", "grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
isPrimary isPrimary
? "bg-white/16 text-white" ? "bg-[var(--surface-reading)]/16 text-white"
: selected || strong : selected || strong
? "bg-blue-600 text-white" ? "bg-[var(--action-blue)] text-white"
: "bg-slate-100 text-slate-500", : "bg-[var(--surface-control)] text-[var(--ink-muted)]",
isDanger && "bg-red-50 text-red-600" isDanger && "bg-red-50 text-red-600"
)} )}
> >
@@ -193,19 +193,19 @@ export function MapActionRow({
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{label}</span> <span className="block truncate text-sm font-semibold">{label}</span>
<span className={cn("block truncate text-xs", isPrimary ? "text-blue-100" : "text-slate-500")}>{description}</span> <span className={cn("block truncate text-xs", isPrimary ? "text-[var(--palette-paper)]" : "text-[var(--ink-muted)]")}>{description}</span>
</span> </span>
{status ? ( {status ? (
<span <span
className={cn( className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold", "shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold",
isPrimary ? "bg-white/15 text-white" : "bg-slate-100 text-slate-500" isPrimary ? "bg-[var(--surface-reading)]/15 text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]"
)} )}
> >
{status} {status}
</span> </span>
) : ( ) : (
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-blue-100" : "text-slate-400")} /> <ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-[var(--palette-paper)]" : "text-[var(--ink-muted)]")} />
)} )}
</button> </button>
); );
@@ -214,7 +214,7 @@ export function MapActionRow({
export function MapMetricTile({ label, value }: { label: string; value: string }) { export function MapMetricTile({ label, value }: { label: string; value: string }) {
return ( return (
<div className={cn("px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}> <div className={cn("px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
<div className="text-xs text-slate-500">{label}</div> <div className="text-xs text-[var(--ink-muted)]">{label}</div>
<div className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</div> <div className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</div>
</div> </div>
); );
@@ -53,19 +53,19 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150", "flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.active item.active
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700", : "text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]",
!item.active && MAP_READABLE_SURFACE_CLASS_NAME, !item.active && MAP_READABLE_SURFACE_CLASS_NAME,
isDanger && "hover:border-red-100 hover:text-red-700", isDanger && "hover:border-red-100 hover:text-red-700",
item.disabled && item.disabled &&
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700" "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"
)} )}
> >
<span <span
className={cn( className={cn(
"grid h-7 w-7 shrink-0 place-items-center", "grid h-7 w-7 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active ? "bg-white/16 text-white" : "bg-slate-100 text-slate-500", item.active ? "bg-[var(--surface-reading)]/16 text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]",
isDanger && "bg-red-50 text-red-600" isDanger && "bg-red-50 text-red-600"
)} )}
> >
@@ -108,8 +108,8 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
"grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-600", "grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-600",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active item.active
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "hover:bg-blue-50 hover:text-blue-700", : "hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]",
isDanger && "hover:bg-red-50 hover:text-red-700", isDanger && "hover:bg-red-50 hover:text-red-700",
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600" item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
)} )}
@@ -120,7 +120,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
<TooltipContent <TooltipContent
side="top" side="top"
sideOffset={7} sideOffset={7}
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg" className="border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
> >
{item.label} {item.label}
</TooltipContent> </TooltipContent>
@@ -58,12 +58,12 @@ export function MapLayerControl({
> >
<div className="flex items-center justify-between gap-3 px-1"> <div className="flex items-center justify-between gap-3 px-1">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}> <span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-soft)] text-[var(--action-blue-hover)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Layers3 size={17} aria-hidden="true" /> <Layers3 size={17} aria-hidden="true" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2> <h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
<p className="mt-0.5 text-xs text-slate-500"> <p className="mt-0.5 text-xs text-[var(--ink-muted)]">
{items.filter((item) => item.visible).length}/{items.length} 可见 {items.filter((item) => item.visible).length}/{items.length} 可见
</p> </p>
</div> </div>
@@ -86,8 +86,8 @@ export function MapLayerControl({
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150", "flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible item.visible
? "surface-control border-blue-100 text-slate-900" ? "surface-control border-[var(--surface-border)] text-slate-900"
: "surface-well border-transparent text-slate-500 hover:border-slate-200 hover:bg-white", : "surface-well border-transparent text-[var(--ink-muted)] hover:border-[var(--surface-divider)] hover:bg-[var(--surface-reading)]",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent" disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)} )}
> >
@@ -95,19 +95,19 @@ export function MapLayerControl({
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center", "grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400" item.visible ? "bg-[var(--action-blue)] text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]"
)} )}
> >
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />} {item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate font-semibold">{item.label}</span> <span className="block truncate font-semibold">{item.label}</span>
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null} {item.description ? <span className="block truncate text-xs text-[var(--ink-muted)]">{item.description}</span> : null}
</span> </span>
<span <span
className={cn( className={cn(
"h-2.5 w-2.5 rounded-full", "h-2.5 w-2.5 rounded-full",
item.visible ? "bg-blue-600" : "bg-slate-300" item.visible ? "bg-[var(--action-blue)]" : "bg-slate-300"
)} )}
aria-hidden="true" aria-hidden="true"
/> />
@@ -8,7 +8,8 @@ import {
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME MAP_READABLE_SURFACE_STRONG_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME
} from "./map-control-styles"; } from "./map-control-styles";
export type MapLayerPanelProps = { export type MapLayerPanelProps = {
@@ -71,27 +72,27 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
<ChevronRight <ChevronRight
size={14} size={14}
aria-hidden="true" aria-hidden="true"
className="shrink-0 text-slate-400 transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90" className="shrink-0 text-[var(--ink-muted)] transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span> <span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span>
{group.description ? ( {group.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-slate-500"> <span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
{group.description} {group.description}
</span> </span>
) : null} ) : null}
</span> </span>
<span className="shrink-0 rounded-full bg-white/75 px-2 py-0.5 text-[10px] font-medium tabular-nums text-slate-500 shadow-xs shadow-slate-950/5"> <span className="shrink-0 rounded-full bg-[var(--surface-reading)]/75 px-2 py-0.5 text-[10px] font-medium tabular-nums text-[var(--ink-muted)] shadow-xs shadow-slate-950/5">
{group.items.filter((item) => item.visible).length}/{group.items.length} {group.items.filter((item) => item.visible).length}/{group.items.length}
</span> </span>
</summary> </summary>
<div className="border-t border-slate-200/70 bg-white/45 p-1.5"> <div className="border-t border-[var(--surface-divider)]/70 bg-[var(--surface-reading)]/45 p-1.5">
{groupLayerItemsByCollection(group.items).map((collection) => ( {groupLayerItemsByCollection(group.items).map((collection) => (
<div key={collection.id} className="not-first:mt-2.5"> <div key={collection.id} className="not-first:mt-2.5">
{collection.label ? ( {collection.label ? (
<div className="mb-1 flex items-center gap-2 px-1.5 text-[10px] font-medium text-slate-500"> <div className="mb-1 flex items-center gap-2 px-1.5 text-[10px] font-medium text-[var(--ink-muted)]">
<span className="truncate">{collection.label}</span> <span className="truncate">{collection.label}</span>
<span className="h-px min-w-3 flex-1 bg-slate-200/80" aria-hidden="true" /> <span className="h-px min-w-3 flex-1 bg-[var(--surface-divider)]/80" aria-hidden="true" />
<span className="shrink-0 tabular-nums">{collection.items.length} 层</span> <span className="shrink-0 tabular-nums">{collection.items.length} 层</span>
</div> </div>
) : null} ) : null}
@@ -181,11 +182,11 @@ function LayerVisibilityRow({
title={meta} title={meta}
onClick={() => onToggleLayer(item.id, !item.visible)} onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn( className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left transition-[background-color,border-color,color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none active:scale-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1", "flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left transition-[background-color,border-color,color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none active:scale-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible item.visible
? "border-blue-200/90 bg-white text-slate-950 shadow-xs shadow-slate-950/5" ? "border-[var(--surface-border)]/90 bg-[var(--surface-reading)] text-slate-950 shadow-xs shadow-slate-950/5"
: "border-transparent bg-slate-100/55 text-slate-600 [@media(hover:hover)]:hover:border-slate-200 [@media(hover:hover)]:hover:bg-white/75", : "border-transparent bg-[var(--surface-control)]/55 text-slate-600 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/75",
disabled && "cursor-not-allowed opacity-55 active:scale-100" disabled && "cursor-not-allowed opacity-55 active:scale-100"
)} )}
> >
@@ -193,7 +194,7 @@ function LayerVisibilityRow({
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold leading-tight">{item.label}</span> <span className="block truncate text-xs font-semibold leading-tight">{item.label}</span>
{meta ? ( {meta ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-slate-500"> <span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
{meta} {meta}
</span> </span>
) : null} ) : null}
@@ -201,7 +202,7 @@ function LayerVisibilityRow({
<span <span
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none", "grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none",
item.visible ? "bg-blue-50 text-blue-700" : "bg-transparent text-slate-400" item.visible ? "bg-[var(--action-soft)] text-[var(--action-blue-hover)]" : "bg-transparent text-[var(--ink-muted)]"
)} )}
> >
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />} {item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
@@ -236,7 +237,7 @@ function LayerVisual({
<span <span
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-slate-50 shadow-inner shadow-slate-950/5 transition-opacity duration-150 motion-reduce:transition-none", "grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-[var(--surface-reading)] shadow-inner shadow-slate-950/5 transition-opacity duration-150 motion-reduce:transition-none",
!visible && "opacity-45" !visible && "opacity-45"
)} )}
> >
@@ -292,8 +293,8 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
return ( return (
<div> <div>
<div className="mb-1.5 flex items-center justify-between px-1"> <div className="mb-1.5 flex items-center justify-between px-1">
<span className="text-xs font-semibold text-slate-500">底图</span> <span className="text-xs font-semibold text-[var(--ink-muted)]">底图</span>
<MapIcon size={14} aria-hidden="true" className="text-slate-400" /> <MapIcon size={14} aria-hidden="true" className="text-[var(--ink-muted)]" />
</div> </div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{options.map((option) => { {options.map((option) => {
@@ -309,10 +310,11 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
className={cn( className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150", "flex min-h-[76px] flex-col p-1 text-left transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME, active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
active active
? "border-blue-500 text-blue-700 ring-2 ring-blue-100" ? "border-[var(--action-blue)] text-[var(--action-blue-hover)]"
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40", : "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40",
option.disabled && "cursor-not-allowed opacity-50" option.disabled && "cursor-not-allowed opacity-50"
)} )}
> >
@@ -339,11 +341,11 @@ function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
className={cn( className={cn(
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner", "relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
option.swatchClassName ?? "bg-slate-100" option.swatchClassName ?? "bg-[var(--surface-control)]"
)} )}
> >
{active ? ( {active ? (
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white"> <span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-[var(--action-blue)] text-white">
<Check size={10} aria-hidden="true" /> <Check size={10} aria-hidden="true" />
</span> </span>
) : null} ) : null}
+11 -11
View File
@@ -40,7 +40,7 @@ export function MapLegend({ title = "图例", items, className = "" }: MapLegend
)} )}
> >
<h2 className="px-1 text-sm font-semibold leading-tight text-slate-950">{title}</h2> <h2 className="px-1 text-sm font-semibold leading-tight text-slate-950">{title}</h2>
<p className="mt-0.5 px-1 text-xs text-slate-500">当前地图符号与分级</p> <p className="mt-0.5 px-1 text-xs text-[var(--ink-muted)]">当前地图符号与分级</p>
<MapLegendList items={items} className="mt-3" /> <MapLegendList items={items} className="mt-3" />
</section> </section>
); );
@@ -58,7 +58,7 @@ export function MapLegendList({ items, className = "" }: MapLegendListProps) {
return ( return (
<div className={cn("surface-well rounded-xl border px-3 py-4 text-center", className)}> <div className={cn("surface-well rounded-xl border px-3 py-4 text-center", className)}>
<p className="text-xs font-semibold text-slate-700">暂无可见业务图层</p> <p className="text-xs font-semibold text-slate-700">暂无可见业务图层</p>
<p className="mt-1 text-[11px] leading-relaxed text-slate-500">请先在图层面板中打开需要查看的内容。</p> <p className="mt-1 text-[11px] leading-relaxed text-[var(--ink-muted)]">请先在图层面板中打开需要查看的内容。</p>
</div> </div>
); );
} }
@@ -75,21 +75,21 @@ export function MapLegendList({ items, className = "" }: MapLegendListProps) {
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span> <span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span>
{group.description ? ( {group.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-slate-500"> <span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
{group.description} {group.description}
</span> </span>
) : null} ) : null}
</span> </span>
<span className="shrink-0 rounded-full bg-white/75 px-2 py-0.5 text-[10px] font-medium tabular-nums text-slate-500 shadow-xs shadow-slate-950/5"> <span className="shrink-0 rounded-full bg-[var(--surface-reading)]/75 px-2 py-0.5 text-[10px] font-medium tabular-nums text-[var(--ink-muted)] shadow-xs shadow-slate-950/5">
{group.items.length} 类 {group.items.length} 类
</span> </span>
</div> </div>
<ul <ul
className={cn( className={cn(
"border-t border-slate-200/70", "border-t border-[var(--surface-divider)]/70",
group.layout === "grid" group.layout === "grid"
? "grid grid-cols-2 gap-px bg-slate-200/70" ? "grid grid-cols-2 gap-px bg-[var(--surface-divider)]/70"
: "divide-y divide-slate-200/70 bg-white/45" : "divide-y divide-[var(--surface-divider)]/70 bg-[var(--surface-reading)]/45"
)} )}
> >
{group.items.map((item) => ( {group.items.map((item) => (
@@ -134,13 +134,13 @@ function LegendItem({ item, compact }: { item: MapLegendItem; compact: boolean }
<li <li
className={cn( className={cn(
"flex items-center text-slate-700", "flex items-center text-slate-700",
compact ? "min-h-11 gap-2 bg-white/70 px-2 py-1.5" : "min-h-9 gap-2.5 px-2.5 py-1.5" compact ? "min-h-11 gap-2 bg-[var(--surface-reading)]/70 px-2 py-1.5" : "min-h-9 gap-2.5 px-2.5 py-1.5"
)} )}
> >
<span <span
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
"grid shrink-0 place-items-center bg-slate-50 shadow-inner shadow-slate-950/5", "grid shrink-0 place-items-center bg-[var(--surface-reading)] shadow-inner shadow-slate-950/5",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
compact ? "h-7 w-7" : "h-7 w-9" compact ? "h-7 w-7" : "h-7 w-9"
)} )}
@@ -156,11 +156,11 @@ function LegendItem({ item, compact }: { item: MapLegendItem; compact: boolean }
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-semibold leading-tight">{item.label}</span> <span className="block truncate text-[11px] font-semibold leading-tight">{item.label}</span>
{compact && item.description ? ( {compact && item.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-slate-500">{item.description}</span> <span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">{item.description}</span>
) : null} ) : null}
</span> </span>
{!compact && item.description ? ( {!compact && item.description ? (
<span className="shrink-0 text-[10px] text-slate-500">{item.description}</span> <span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{item.description}</span>
) : null} ) : null}
</li> </li>
); );
@@ -10,6 +10,9 @@ export const MAP_COMPACT_RADIUS_CLASS_NAME = MAP_CONTROL_RADIUS_CLASS_NAME;
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg"; export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
export const MAP_SELECTION_CARD_FOCUS_CLASS_NAME =
"outline-hidden focus-visible:outline-1 focus-visible:outline-solid focus-visible:outline-[var(--action-focus-ring)] focus-visible:-outline-offset-1";
export const MAP_CONTROL_SURFACE_CLASS_NAME = export const MAP_CONTROL_SURFACE_CLASS_NAME =
"acrylic-control border"; "acrylic-control border";
@@ -79,7 +79,7 @@ export function MapMeasurePanel({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<section> <section>
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500 max-lg:hidden"> <div className="mb-1.5 px-1 text-xs font-semibold text-[var(--ink-muted)] max-lg:hidden">
测量类型 测量类型
</div> </div>
<div <div
@@ -98,8 +98,8 @@ export function MapMeasurePanel({
"flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none", "flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
mode.id === activeModeId mode.id === activeModeId
? "bg-blue-600 text-white shadow-[var(--action-shadow-primary)]" ? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-white/85 [@media(hover:hover)]:hover:text-blue-700", : "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]",
mode.disabled && "cursor-not-allowed opacity-45 active:scale-100" mode.disabled && "cursor-not-allowed opacity-45 active:scale-100"
)} )}
> >
@@ -117,12 +117,12 @@ export function MapMeasurePanel({
<div className="px-3.5 py-2.5"> <div className="px-3.5 py-2.5">
<div className="flex min-w-0 items-start justify-between gap-3"> <div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 pt-0.5"> <div className="min-w-0 pt-0.5">
<div className="text-xs font-semibold text-blue-700">{resultLabel}</div> <div className="text-xs font-semibold text-[var(--action-blue-hover)]">{resultLabel}</div>
<div className="mt-1.5 flex min-w-0 items-baseline gap-1.5 text-slate-950"> <div className="mt-1.5 flex min-w-0 items-baseline gap-1.5 text-slate-950">
<span className="truncate text-[32px] font-semibold leading-none tracking-[-0.02em] tabular-nums"> <span className="truncate text-[32px] font-semibold leading-none tracking-[-0.02em] tabular-nums">
{resultValue} {resultValue}
</span> </span>
<span className="shrink-0 text-xs font-semibold text-slate-500">{resultUnit}</span> <span className="shrink-0 text-xs font-semibold text-[var(--ink-muted)]">{resultUnit}</span>
</div> </div>
</div> </div>
<div <div
@@ -140,8 +140,8 @@ export function MapMeasurePanel({
"h-10 min-w-10 touch-manipulation px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none", "h-10 min-w-10 touch-manipulation px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
unit.id === activeUnitId unit.id === activeUnitId
? "bg-blue-600 text-white shadow-[var(--action-shadow-primary)]" ? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-white/85 [@media(hover:hover)]:hover:text-blue-700" : "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
)} )}
> >
{unit.label} {unit.label}
@@ -152,13 +152,13 @@ export function MapMeasurePanel({
</div> </div>
{metrics.length > 0 ? ( {metrics.length > 0 ? (
<dl className="grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] border-t border-slate-200/80"> <dl className="grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] border-t border-[var(--surface-divider)]/80">
{metrics.map((metric, index) => ( {metrics.map((metric, index) => (
<div <div
key={metric.label} key={metric.label}
className={cn("min-w-0 px-3.5 py-2", index > 0 && "border-l border-slate-200/80")} className={cn("min-w-0 px-3.5 py-2", index > 0 && "border-l border-[var(--surface-divider)]/80")}
> >
<dt className="truncate text-[11px] text-slate-500">{metric.label}</dt> <dt className="truncate text-[11px] text-[var(--ink-muted)]">{metric.label}</dt>
<dd className="mt-0.5 truncate text-sm font-semibold tabular-nums text-slate-900"> <dd className="mt-0.5 truncate text-sm font-semibold tabular-nums text-slate-900">
{metric.value} {metric.value}
</dd> </dd>
@@ -172,28 +172,28 @@ export function MapMeasurePanel({
className={cn( className={cn(
"flex min-h-10 items-center gap-2.5 border-t px-3.5 py-2", "flex min-h-10 items-center gap-2.5 border-t px-3.5 py-2",
status.tone === "active" status.tone === "active"
? "border-blue-100 bg-blue-50/90" ? "border-[var(--surface-border)] bg-[var(--action-soft)]/90"
: status.tone === "ready" : status.tone === "ready"
? "border-emerald-100 bg-emerald-50/80" ? "border-emerald-100 bg-emerald-50/80"
: "border-slate-200/80 bg-slate-50/75" : "border-[var(--surface-divider)]/80 bg-[var(--surface-reading)]/75"
)} )}
> >
<span <span
className={cn( className={cn(
"h-2 w-2 shrink-0 rounded-full", "h-2 w-2 shrink-0 rounded-full",
status.tone === "active" status.tone === "active"
? "bg-blue-600 motion-safe:animate-pulse" ? "bg-[var(--action-blue)] motion-safe:animate-pulse"
: status.tone === "ready" : status.tone === "ready"
? "bg-emerald-500" ? "bg-emerald-500"
: status.tone === "disabled" : status.tone === "disabled"
? "bg-amber-400" ? "bg-[var(--status-warning-mark)]"
: "bg-slate-400" : "bg-slate-400"
)} )}
aria-hidden="true" aria-hidden="true"
/> />
<div className="flex min-w-0 items-baseline gap-2"> <div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold text-slate-800">{status.label}</div> <div className="shrink-0 text-xs font-semibold text-slate-800">{status.label}</div>
<div className="truncate text-[11px] text-slate-500">{status.description}</div> <div className="truncate text-[11px] text-[var(--ink-muted)]">{status.description}</div>
</div> </div>
</div> </div>
</section> </section>
@@ -238,18 +238,18 @@ function MeasureActionButton({
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
primary primary
? active ? active
? "border-blue-300 bg-white text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-blue-100 [@media(hover:hover)]:hover:border-blue-400 [@media(hover:hover)]:hover:bg-blue-50/70" ? "border-[var(--palette-brand)] bg-[var(--surface-reading)] text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-[var(--surface-border)] [@media(hover:hover)]:hover:border-[var(--palette-brand)] [@media(hover:hover)]:hover:bg-[var(--action-soft)]/70"
: action.variant === "primary" : action.variant === "primary"
? "border-slate-200 bg-slate-50/90 text-slate-900 shadow-xs [@media(hover:hover)]:hover:border-slate-300 [@media(hover:hover)]:hover:bg-white" ? "border-[var(--surface-divider)] bg-[var(--surface-reading)]/90 text-slate-900 shadow-xs [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]"
: "border-slate-800 bg-slate-800 text-white shadow-xs [@media(hover:hover)]:hover:border-slate-900 [@media(hover:hover)]:hover:bg-slate-900" : "border-slate-800 bg-slate-800 text-white shadow-xs [@media(hover:hover)]:hover:border-slate-900 [@media(hover:hover)]:hover:bg-slate-900"
: cn( : cn(
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
danger danger
? "text-red-600 [@media(hover:hover)]:hover:border-red-200 [@media(hover:hover)]:hover:bg-red-50" ? "text-red-600 [@media(hover:hover)]:hover:border-red-200 [@media(hover:hover)]:hover:bg-red-50"
: "text-slate-700 [@media(hover:hover)]:hover:border-blue-200 [@media(hover:hover)]:hover:text-blue-700" : "text-slate-700 [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
), ),
action.disabled && action.disabled &&
"cursor-not-allowed opacity-45 shadow-none active:scale-100 [@media(hover:hover)]:hover:border-slate-200 [@media(hover:hover)]:hover:bg-transparent [@media(hover:hover)]:hover:text-slate-700" "cursor-not-allowed opacity-45 shadow-none active:scale-100 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-transparent [@media(hover:hover)]:hover:text-slate-700"
)} )}
> >
{primary ? ( {primary ? (
@@ -257,8 +257,8 @@ function MeasureActionButton({
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg", "grid h-8 w-8 shrink-0 place-items-center rounded-lg",
active active
? "bg-blue-600 text-white" ? "bg-[var(--action-blue)] text-white"
: "bg-white text-slate-700 ring-1 ring-inset ring-slate-200" : "bg-[var(--surface-reading)] text-slate-700 ring-1 ring-inset ring-[var(--surface-divider)]"
)} )}
> >
<Icon size={15} strokeWidth={2.2} aria-hidden="true" /> <Icon size={15} strokeWidth={2.2} aria-hidden="true" />
@@ -269,7 +269,7 @@ function MeasureActionButton({
{primary ? ( {primary ? (
<span className="min-w-0 flex-1 text-left"> <span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-semibold leading-4">{action.label}</span> <span className="block truncate text-sm font-semibold leading-4">{action.label}</span>
<span className="mt-0.5 block truncate text-[10px] font-medium leading-3 text-slate-500"> <span className="mt-0.5 block truncate text-[10px] font-medium leading-3 text-[var(--ink-muted)]">
{action.description} {action.description}
</span> </span>
</span> </span>
@@ -278,12 +278,12 @@ function MeasureActionButton({
)} )}
{primary ? ( {primary ? (
active ? ( active ? (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-[10px] font-semibold text-blue-700 ring-1 ring-inset ring-blue-200"> <span className="flex shrink-0 items-center gap-1 rounded-full bg-[var(--action-soft)] px-2 py-1 text-[10px] font-semibold text-[var(--action-blue-hover)] ring-1 ring-inset ring-[var(--surface-border)]">
<span className="h-1.5 w-1.5 rounded-full bg-blue-600 motion-safe:animate-pulse" aria-hidden="true" /> <span className="h-1.5 w-1.5 rounded-full bg-[var(--action-blue)] motion-safe:animate-pulse" aria-hidden="true" />
进行中 进行中
</span> </span>
) : ( ) : (
<ChevronRight size={16} aria-hidden="true" className="shrink-0 text-slate-400" /> <ChevronRight size={16} aria-hidden="true" className="shrink-0 text-[var(--ink-muted)]" />
) )
) : null} ) : null}
</button> </button>
@@ -44,7 +44,7 @@ export function showMapNotice(options: MapNoticeOptions) {
: undefined, : undefined,
classNames: { classNames: {
cancelButton: cn( cancelButton: cn(
"pointer-events-auto absolute right-2 top-2 z-10 grid h-8 w-8 place-items-center border border-transparent bg-transparent p-0 text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700", "pointer-events-auto absolute right-2 top-2 z-10 grid h-8 w-8 place-items-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:border-[var(--surface-divider)] hover:bg-[var(--surface-reading)] hover:text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME MAP_ICON_CELL_RADIUS_CLASS_NAME
) )
}, },
@@ -118,7 +118,7 @@ function MapNoticeAction({ label, onClick }: { label: string; onClick: () => voi
size="sm" size="sm"
variant="outline" variant="outline"
className={cn( className={cn(
"pointer-events-auto mt-2 border-blue-100 bg-blue-50 text-blue-700", "pointer-events-auto mt-2 border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
MAP_COMPACT_RADIUS_CLASS_NAME MAP_COMPACT_RADIUS_CLASS_NAME
)} )}
> >
+1 -1
View File
@@ -17,7 +17,7 @@ export function MapToaster() {
classNames: { classNames: {
toast: "group pointer-events-none", toast: "group pointer-events-none",
closeButton: closeButton:
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-slate-400 after:absolute after:-inset-1 after:content-[''] hover:bg-white/70 hover:text-slate-700" "pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
} }
}} }}
/> />
@@ -125,7 +125,7 @@ export function MapScaleLine({
) : ( ) : (
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums")}>{coordinateLabel}</span> <span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums")}>{coordinateLabel}</span>
)} )}
<span className="text-slate-500">Mapbox/OSM</span> <span className="text-[var(--ink-muted)]">Mapbox/OSM</span>
</div> </div>
</div> </div>
); );
+3 -3
View File
@@ -70,9 +70,9 @@ export function MapToolbar({
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700", "relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
tool.active tool.active
? "border-blue-600 bg-blue-600 text-white shadow-[var(--action-shadow-primary)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: MAP_CONTROL_HOVER_CLASS_NAME, : MAP_CONTROL_HOVER_CLASS_NAME,
tool.disabled && "text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400 hover:ring-0" tool.disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)] hover:ring-0"
)} )}
> >
<Icon size={20} strokeWidth={2.15} aria-hidden="true" /> <Icon size={20} strokeWidth={2.15} aria-hidden="true" />
@@ -87,7 +87,7 @@ export function MapToolbar({
<TooltipContent <TooltipContent
side={isVertical ? "left" : "top"} side={isVertical ? "left" : "top"}
sideOffset={8} sideOffset={8}
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg" className="border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
> >
{title} {title}
</TooltipContent> </TooltipContent>
+5 -5
View File
@@ -159,8 +159,8 @@ function ControlButton({
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
"bg-transparent text-slate-700", "bg-transparent text-slate-700",
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
pressed && "bg-blue-50 text-blue-700", pressed && "bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
disabled && "text-slate-400 opacity-45 hover:bg-transparent hover:text-slate-400" disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)]"
)} )}
> >
{Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content} {Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content}
@@ -169,7 +169,7 @@ function ControlButton({
<TooltipContent <TooltipContent
side="left" side="left"
sideOffset={8} sideOffset={8}
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg" className="border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
> >
{label} {label}
</TooltipContent> </TooltipContent>
@@ -206,8 +206,8 @@ function CompassModeIndicator({
className={cn( className={cn(
"absolute -bottom-0.5 -right-0.5 min-w-4 rounded-[4px] px-0.5 text-center text-[8px] font-bold leading-3 tracking-[-0.02em] shadow-xs ring-1", "absolute -bottom-0.5 -right-0.5 min-w-4 rounded-[4px] px-0.5 text-center text-[8px] font-bold leading-3 tracking-[-0.02em] shadow-xs ring-1",
mode === "3D" mode === "3D"
? "bg-blue-600 text-white ring-blue-700/30" ? "bg-[var(--action-blue)] text-white ring-[var(--action-blue)]/30"
: "bg-white text-slate-600 ring-slate-300" : "bg-[var(--surface-reading)] text-slate-600 ring-[var(--surface-border)]"
)} )}
> >
{mode} {mode}
@@ -14,6 +14,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { AgentExecutionPlan } from "@/features/agent"; import type { AgentExecutionPlan } from "@/features/agent";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure"; import { Disclosure } from "@/shared/ui/disclosure";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
@@ -122,8 +123,8 @@ export function AgentExecutionPlanPanel({
return ( return (
<section aria-label="工况任务处理" className="min-w-0 space-y-2.5"> <section aria-label="工况任务处理" className="min-w-0 space-y-2.5">
<section className="surface-reading overflow-hidden rounded-xl border border-slate-200/70"> <section className="surface-reading overflow-hidden rounded-xl border border-[var(--surface-divider)]/70">
<div className="space-y-2 border-b border-slate-200/70 p-3"> <div className="space-y-2 border-b border-[var(--surface-divider)]/70 p-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"> <h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800">
<Route size={13} aria-hidden="true" /> <Route size={13} aria-hidden="true" />
@@ -136,7 +137,7 @@ export function AgentExecutionPlanPanel({
<p className="text-xs leading-5 text-slate-600" role="status"> <p className="text-xs leading-5 text-slate-600" role="status">
{planSummary} {planSummary}
</p> </p>
<p className="text-[11px] leading-5 text-slate-500"> <p className="text-[11px] leading-5 text-[var(--ink-muted)]">
{taskTypeLabel(task.task_type)} · 更新于 {formatDateTime(task.updated_at)} {taskTypeLabel(task.task_type)} · 更新于 {formatDateTime(task.updated_at)}
</p> </p>
</div> </div>
@@ -145,7 +146,7 @@ export function AgentExecutionPlanPanel({
) : plan ? ( ) : plan ? (
<ExecutionPlanSteps plan={plan} nodeActions={nodeActions} /> <ExecutionPlanSteps plan={plan} nodeActions={nodeActions} />
) : ( ) : (
<p className="p-3 text-xs leading-5 text-slate-500">尚无执行节点,页面不会按时间推算或虚构完成进度。</p> <p className="p-3 text-xs leading-5 text-[var(--ink-muted)]">尚无执行节点,页面不会按时间推算或虚构完成进度。</p>
)} )}
</section> </section>
@@ -281,13 +282,13 @@ function SemanticPath({ nodeId, stopId, plan, activeEdges, nodeById, nodeActions
const branchEnd = join?.id ?? stopId; const branchEnd = join?.id ?? stopId;
return <div className="space-y-2"> return <div className="space-y-2">
<ExecutionNodeCard node={node} action={nodeActions[node.id]} /> <ExecutionNodeCard node={node} action={nodeActions[node.id]} />
{node.kind === "decision" ? <div className="rounded-lg bg-blue-50/70 px-3 py-2 text-xs leading-5 text-slate-700"> {node.kind === "decision" ? <div className="rounded-lg bg-[var(--action-soft)]/70 px-3 py-2 text-xs leading-5 text-slate-700">
<span className="font-semibold">路线选择:</span>{allRoutes.find((edge) => edge.id === node.selected_edge_id)?.condition ?? allRoutes.find((edge) => edge.id === node.selected_edge_id)?.label ?? "等待判断"} <span className="font-semibold">路线选择:</span>{allRoutes.find((edge) => edge.id === node.selected_edge_id)?.condition ?? allRoutes.find((edge) => edge.id === node.selected_edge_id)?.label ?? "等待判断"}
<div className="mt-1 text-slate-500">{allRoutes.map((edge) => `${edge.condition ?? edge.label ?? "未命名路线"}${edge.id === node.selected_edge_id ? "(已选择)" : ""}`).join(" · ")}</div> <div className="mt-1 text-[var(--ink-muted)]">{allRoutes.map((edge) => `${edge.condition ?? edge.label ?? "未命名路线"}${edge.id === node.selected_edge_id ? "(已选择)" : ""}`).join(" · ")}</div>
</div> : null} </div> : null}
{node.kind === "parallel_split" ? <section aria-label={`并行处理 ${outgoing.length} 路`} className="min-w-0 rounded-xl border border-blue-100 bg-blue-50/40 p-2"> {node.kind === "parallel_split" ? <section aria-label={`并行处理 ${outgoing.length} 路`} className="min-w-0 rounded-xl border border-[var(--surface-border)] bg-[var(--action-soft)]/40 p-2">
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-blue-800"><GitFork size={13} aria-hidden="true" />并行处理 · {outgoing.length} 路</p> <p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-[var(--action-blue-pressed)]"><GitFork size={13} aria-hidden="true" />并行处理 · {outgoing.length} 路</p>
<div className="grid gap-2 sm:grid-cols-2">{outgoing.map((edge, index) => <div key={edge.id ?? edge.to} className="min-w-0 rounded-lg border border-slate-200/70 bg-white p-2"> <div className="grid gap-2 sm:grid-cols-2">{outgoing.map((edge, index) => <div key={edge.id ?? edge.to} className="min-w-0 rounded-lg border border-[var(--surface-divider)]/70 bg-[var(--surface-reading)] p-2">
<p className="mb-1.5 text-[11px] font-semibold text-slate-600">分支 {index + 1} · {edge.condition ?? edge.label ?? nodeById.get(edge.to)?.title}</p> <p className="mb-1.5 text-[11px] font-semibold text-slate-600">分支 {index + 1} · {edge.condition ?? edge.label ?? nodeById.get(edge.to)?.title}</p>
<SemanticPath nodeId={edge.to} stopId={branchEnd} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} /> <SemanticPath nodeId={edge.to} stopId={branchEnd} plan={plan} activeEdges={activeEdges} nodeById={nodeById} nodeActions={nodeActions} seen={nextSeen} />
</div>)}</div> </div>)}</div>
@@ -308,21 +309,21 @@ function findSemanticJoin(outgoing: ExecutionEdge[], activeEdges: ExecutionEdge[
function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) { function ExactDependencyList({ plan, nodeActions }: { plan: ExecutionPlanView; nodeActions: Record<string, ReactNode> }) {
const titleById = new Map(plan.nodes.map((node) => [node.id, node.title])); const titleById = new Map(plan.nodes.map((node) => [node.id, node.title]));
return <div><p className="mb-2 text-[11px] leading-5 text-slate-500">历史流程按原始依赖展示;未标注路线或并行类型。</p><ol className="grid gap-2">{topologicalNodes(plan).map((node) => { return <div><p className="mb-2 text-[11px] leading-5 text-[var(--ink-muted)]">历史流程按原始依赖展示;未标注路线或并行类型。</p><ol className="grid gap-2">{topologicalNodes(plan).map((node) => {
const prerequisites = plan.edges.filter((edge) => edge.to === node.id); const prerequisites = plan.edges.filter((edge) => edge.to === node.id);
return <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} compact />{prerequisites.length > 0 ? <p className="px-2 pt-1 text-[11px] leading-5 text-slate-500">前置:{prerequisites.map((edge) => `${titleById.get(edge.from) ?? edge.from}${edge.condition || edge.label ? `(${edge.condition ?? edge.label})` : ""}`).join("、")}</p> : null}</li>; return <li key={node.id}><ExecutionNodeCard node={node} action={nodeActions[node.id]} compact />{prerequisites.length > 0 ? <p className="px-2 pt-1 text-[11px] leading-5 text-[var(--ink-muted)]">前置:{prerequisites.map((edge) => `${titleById.get(edge.from) ?? edge.from}${edge.condition || edge.label ? `(${edge.condition ?? edge.label})` : ""}`).join("、")}</p> : null}</li>;
})}</ol></div>; })}</ol></div>;
} }
function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) { function ExecutionNodeCard({ node, action, compact = false }: { node: ExecutionNode; action?: ReactNode; compact?: boolean }) {
const { tone, Icon, label } = executionNodePresentation(node); const { tone, Icon, label } = executionNodePresentation(node);
return ( return (
<article data-execution-node={node.id} className={cn("surface-reading min-w-0 rounded-xl border border-slate-200/70", compact ? "px-2.5 py-2" : "px-3 py-2.5")}> <article data-execution-node={node.id} className={cn("surface-reading min-w-0 rounded-xl border border-[var(--surface-divider)]/70", compact ? "px-2.5 py-2" : "px-3 py-2.5")}>
<div className="flex min-w-0 items-start gap-2"> <div className="flex min-w-0 items-start gap-2">
<StatusIcon tone={tone} activity={node.status === "running" ? "loading" : "static"} shape="circle" size="md" className="mt-0.5 shrink-0"><Icon size={14} /></StatusIcon> <StatusIcon tone={tone} activity={node.status === "running" ? "loading" : "static"} shape="circle" size="md" className="mt-0.5 shrink-0"><Icon size={14} /></StatusIcon>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2"><span className="text-xs font-semibold leading-5 text-slate-900">{node.title}</span><StatusBadge tone={tone}>{label}</StatusBadge></div> <div className="flex min-w-0 flex-wrap items-center justify-between gap-2"><span className="text-xs font-semibold leading-5 text-slate-900">{node.title}</span><StatusBadge tone={tone}>{label}</StatusBadge></div>
{node.kind && node.kind !== "activity" ? <p className="mt-0.5 text-[11px] font-medium text-blue-700">{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "等待审核" }[node.kind]}</p> : null} {node.kind && node.kind !== "activity" ? <p className="mt-0.5 text-[11px] font-medium text-[var(--action-blue-hover)]">{{ decision: "条件判断", parallel_split: "并行分叉", parallel_join: "并行汇合", gate: "等待审核" }[node.kind]}</p> : null}
<p className="mt-1 break-words text-xs leading-5 text-slate-700">{node.detail || (node.owner === "human" ? "等待人工补充执行事实。" : "等待 Agent 更新执行事实。")}</p> <p className="mt-1 break-words text-xs leading-5 text-slate-700">{node.detail || (node.owner === "human" ? "等待人工补充执行事实。" : "等待 Agent 更新执行事实。")}</p>
{action ? <div className="mt-2">{action}</div> : null} {action ? <div className="mt-2">{action}</div> : null}
</div> </div>
@@ -338,11 +339,11 @@ function ExternalProgress({ evidence }: { evidence: OperationalTaskEvidence[] })
{ordered.map((item, index) => ( {ordered.map((item, index) => (
<li key={item.evidence_id} className="grid grid-cols-[28px_1fr] gap-2"> <li key={item.evidence_id} className="grid grid-cols-[28px_1fr] gap-2">
<span className="relative flex justify-center"> <span className="relative flex justify-center">
{index < ordered.length - 1 ? <span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" /> : null} {index < ordered.length - 1 ? <span className="absolute bottom-[-0.65rem] top-7 w-px bg-[var(--surface-divider)]" aria-hidden="true" /> : null}
<StatusIcon tone="info" shape="circle" size="md" className="relative z-10 ring-4 ring-white"><Clock3 size={14} /></StatusIcon> <StatusIcon tone="info" shape="circle" size="md" className="relative z-10 ring-4 ring-white"><Clock3 size={14} /></StatusIcon>
</span> </span>
<article className="surface-reading rounded-xl border border-slate-200/70 px-3 py-2.5"> <article className="surface-reading rounded-xl border border-[var(--surface-divider)]/70 px-3 py-2.5">
<div className="flex flex-wrap items-center justify-between gap-2"><span className="text-xs font-semibold text-slate-900">{item.source_label}</span><span className="font-mono text-[11px] text-slate-500">{formatDateTime(item.observed_at)}</span></div> <div className="flex flex-wrap items-center justify-between gap-2"><span className="text-xs font-semibold text-slate-900">{item.source_label}</span><span className="font-mono text-[11px] text-[var(--ink-muted)]">{formatDateTime(item.observed_at)}</span></div>
<p className="mt-1 text-xs leading-5 text-slate-700">{item.summary}</p> <p className="mt-1 text-xs leading-5 text-slate-700">{item.summary}</p>
</article> </article>
</li> </li>
@@ -357,9 +358,9 @@ function EvidenceSection({ evidence, referencedIds, onLoadSnapshot }: { evidence
return [...evidence].sort((left, right) => Number(referenced.has(right.evidence_id)) - Number(referenced.has(left.evidence_id))); return [...evidence].sort((left, right) => Number(referenced.has(right.evidence_id)) - Number(referenced.has(left.evidence_id)));
}, [evidence, referencedIds]); }, [evidence, referencedIds]);
return ( return (
<section aria-label="关键证据" className="surface-reading rounded-xl border border-slate-200/70 p-3"> <section aria-label="关键证据" className="surface-reading rounded-xl border border-[var(--surface-divider)]/70 p-3">
<div className="flex items-center justify-between gap-2"><h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><ShieldCheck size={13} />关键证据</h3><span className="text-[11px] text-slate-500">{ordered.length} 条</span></div> <div className="flex items-center justify-between gap-2"><h3 className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><ShieldCheck size={13} />关键证据</h3><span className="text-[11px] text-[var(--ink-muted)]">{ordered.length} 条</span></div>
{ordered.length > 0 ? <div className="mt-2 grid gap-1.5">{ordered.map((item) => <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadSnapshot} />)}</div> : <p className="mt-2 text-xs leading-5 text-slate-500">尚无可核验的支撑证据。</p>} {ordered.length > 0 ? <div className="mt-2 grid gap-1.5">{ordered.map((item) => <EvidenceDisclosure key={item.evidence_id} item={item} onLoadSnapshot={onLoadSnapshot} />)}</div> : <p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">尚无可核验的支撑证据。</p>}
</section> </section>
); );
} }
@@ -374,8 +375,8 @@ function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvi
catch (nextError) { setError(nextError instanceof Error ? nextError.message : "数据快照读取失败。"); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : "数据快照读取失败。"); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
return <Disclosure label={<span className="line-clamp-2 text-left text-xs leading-5 text-slate-700">{item.summary}</span>} className="surface-control rounded-lg border border-slate-200/70 px-2.5"> return <Disclosure label={<span className="line-clamp-2 text-left text-xs leading-5 text-slate-700">{item.summary}</span>} className="surface-control rounded-lg border border-[var(--surface-divider)]/70 px-2.5">
<div className="border-t border-slate-200/70 py-2"> <div className="border-t border-[var(--surface-divider)]/70 py-2">
<dl className="grid gap-1 text-[11px] leading-5"><EvidenceMeta label="来源" value={item.source_label} /><EvidenceMeta label="时间" value={formatDateTime(item.observed_at)} /><EvidenceMeta label="类型" value={evidenceKindLabel(item.kind)} />{item.source_ref ? <EvidenceMeta label="引用" value={item.source_ref} mono /> : null}</dl> <dl className="grid gap-1 text-[11px] leading-5"><EvidenceMeta label="来源" value={item.source_label} /><EvidenceMeta label="时间" value={formatDateTime(item.observed_at)} /><EvidenceMeta label="类型" value={evidenceKindLabel(item.kind)} />{item.source_ref ? <EvidenceMeta label="引用" value={item.source_ref} mono /> : null}</dl>
{item.has_snapshot && !snapshot ? <Button className="mt-2" variant="outline" size="sm" disabled={loading} onClick={() => void load()}>{loading ? <Loader2 className="animate-spin" /> : null}查看数据快照</Button> : null} {item.has_snapshot && !snapshot ? <Button className="mt-2" variant="outline" size="sm" disabled={loading} onClick={() => void load()}>{loading ? <Loader2 className="animate-spin" /> : null}查看数据快照</Button> : null}
{error ? <p role="alert" className="mt-2 text-xs text-red-700">{error}</p> : null} {error ? <p role="alert" className="mt-2 text-xs text-red-700">{error}</p> : null}
@@ -386,7 +387,7 @@ function EvidenceDisclosure({ item, onLoadSnapshot }: { item: OperationalTaskEvi
function SnapshotSummary({ snapshot }: { snapshot: OperationalTaskDataSnapshot }) { function SnapshotSummary({ snapshot }: { snapshot: OperationalTaskDataSnapshot }) {
const rows = snapshotRows(snapshot.data).slice(0, 12); const rows = snapshotRows(snapshot.data).slice(0, 12);
return <section aria-label="数据快照" className="mt-2 rounded-lg bg-white px-2.5 py-2 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.3)]"><p className="text-[11px] font-semibold text-slate-700">{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}</p><dl className="mt-1 grid gap-1 text-[11px] leading-5">{rows.map(([key, value]) => <EvidenceMeta key={key} label={key} value={value} />)}</dl>{snapshot.truncated ? <p className="mt-1 text-[11px] text-amber-700">数据已按有界查询范围截断。</p> : null}</section>; return <section aria-label="数据快照" className="mt-2 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.3)]"><p className="text-[11px] font-semibold text-slate-700">{snapshot.source_ref} · 查询于 {formatDateTime(snapshot.queried_at)}</p><dl className="mt-1 grid gap-1 text-[11px] leading-5">{rows.map(([key, value]) => <EvidenceMeta key={key} label={key} value={value} />)}</dl>{snapshot.truncated ? <p className="mt-1 text-[11px] text-[var(--status-warning-foreground)]">数据已按有界查询范围截断。</p> : null}</section>;
} }
function snapshotRows(value: unknown, prefix = ""): [string, string][] { function snapshotRows(value: unknown, prefix = ""): [string, string][] {
@@ -396,7 +397,7 @@ function snapshotRows(value: unknown, prefix = ""): [string, string][] {
} }
function EvidenceMeta({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { function EvidenceMeta({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return <div className="grid grid-cols-[48px_1fr] gap-2"><dt className="font-semibold text-slate-500">{label}</dt><dd className={cn("break-words text-slate-700", mono && "font-mono")}>{value}</dd></div>; return <div className="grid grid-cols-[48px_1fr] gap-2"><dt className="font-semibold text-[var(--ink-muted)]">{label}</dt><dd className={cn("break-words text-slate-700", mono && "font-mono")}>{value}</dd></div>;
} }
export function AccessRequestCard({ request, nodeTitle, canDecide, onDecision, onRefresh }: { export function AccessRequestCard({ request, nodeTitle, canDecide, onDecision, onRefresh }: {
@@ -419,14 +420,14 @@ export function AccessRequestCard({ request, nodeTitle, canDecide, onDecision, o
if (/409|版本|conflict/i.test(String(nextError))) onRefresh(); if (/409|版本|conflict/i.test(String(nextError))) onRefresh();
} finally { setBusy(false); } } finally { setBusy(false); }
}; };
return <article className="surface-reading rounded-xl border border-amber-200/80 p-3"> return <article className="surface-reading rounded-xl border border-[var(--status-warning-border)]/80 p-3">
<div className="flex items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">数据访问申请</h3><StatusBadge tone={request.status === "approved" ? "success" : request.status === "rejected" ? "danger" : "warning"}>{request.status === "approved" ? "已批准" : request.status === "rejected" ? "已拒绝" : "待审核"}</StatusBadge></div> <div className="flex items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">数据访问申请</h3><StatusBadge tone={request.status === "approved" ? "success" : request.status === "rejected" ? "danger" : "warning"}>{request.status === "approved" ? "已批准" : request.status === "rejected" ? "已拒绝" : "待审核"}</StatusBadge></div>
<p className="mt-1 text-xs leading-5 text-slate-700">{request.reason}</p> <p className="mt-1 text-xs leading-5 text-slate-700">{request.reason}</p>
<p className="mt-1 text-[11px] text-slate-500">关联步骤 {nodeTitle ?? request.node_id} · 申请于 {formatDateTime(request.requested_at)}</p> <p className="mt-1 text-[11px] text-[var(--ink-muted)]">关联步骤 {nodeTitle ?? request.node_id} · 申请于 {formatDateTime(request.requested_at)}</p>
<div className="mt-2 grid gap-1">{request.scopes.map((scope) => <label key={scope} className="flex min-h-10 items-center gap-2 text-xs text-slate-700"><input type="checkbox" checked={selected.includes(scope)} disabled={request.status !== "pending" || !canDecide || busy} onChange={(event) => setSelected((current) => event.target.checked ? [...current, scope] : current.filter((item) => item !== scope))} />{dataScopeLabel(scope)}</label>)}</div> <div className="mt-2 grid gap-1">{request.scopes.map((scope) => <label key={scope} className="flex min-h-10 items-center gap-2 text-xs text-slate-700"><Checkbox checked={selected.includes(scope)} disabled={request.status !== "pending" || !canDecide || busy} onChange={(event) => setSelected((current) => event.target.checked ? [...current, scope] : current.filter((item) => item !== scope))} />{dataScopeLabel(scope)}</label>)}</div>
{request.status !== "pending" ? <p className="mt-1 text-xs text-slate-600">{request.status === "approved" ? `已授权:${request.approved_scopes.map(dataScopeLabel).join("、")}` : "未授予数据访问权限"}</p> : null} {request.status !== "pending" ? <p className="mt-1 text-xs text-slate-600">{request.status === "approved" ? `已授权:${request.approved_scopes.map(dataScopeLabel).join("、")}` : "未授予数据访问权限"}</p> : null}
{request.status === "pending" && canDecide && onDecision ? <div className="mt-2 flex justify-end gap-2"><Button variant="outline" size="sm" disabled={busy} onClick={() => void decide("rejected")}>拒绝</Button><Button size="sm" disabled={busy || selected.length === 0} onClick={() => void decide("approved")}>批准所选范围</Button></div> : null} {request.status === "pending" && canDecide && onDecision ? <div className="mt-2 flex justify-end gap-2"><Button variant="outline" size="sm" disabled={busy} onClick={() => void decide("rejected")}>拒绝</Button><Button size="sm" disabled={busy || selected.length === 0} onClick={() => void decide("approved")}>批准所选范围</Button></div> : null}
{request.status === "pending" && !canDecide ? <p className="mt-2 text-xs text-slate-500">等待具有该数据源读取权限的项目成员审核。</p> : null} {request.status === "pending" && !canDecide ? <p className="mt-2 text-xs text-[var(--ink-muted)]">等待具有该数据源读取权限的项目成员审核。</p> : null}
{error ? <p role="alert" className="mt-2 text-xs text-red-700">{error}</p> : null} {error ? <p role="alert" className="mt-2 text-xs text-red-700">{error}</p> : null}
</article>; </article>;
} }
@@ -478,25 +479,25 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
} finally { setSubmitting(null); } } finally { setSubmitting(null); }
}; };
return ( return (
<article className="surface-reading rounded-xl border border-amber-200/80 p-3" data-action-id={action.action_id}> <article className="surface-reading rounded-xl border border-[var(--status-warning-border)]/80 p-3" data-action-id={action.action_id}>
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
<StatusIcon tone="warning" shape="circle" size="md" className="mt-0.5"><UserCheck size={14} /></StatusIcon> <StatusIcon tone="warning" shape="circle" size="md" className="mt-0.5"><UserCheck size={14} /></StatusIcon>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">{command ? "命令下发确认" : "工单创建确认"}</h3><div className="flex flex-wrap gap-1.5"><StatusBadge tone={approvalTone(action.approval_status)}>{approvalLabel(action.approval_status)}</StatusBadge><StatusBadge tone={executionTone(action.execution_status)}>{executionLabel(action.execution_status)}</StatusBadge></div></div> <div className="flex flex-wrap items-center justify-between gap-2"><h3 className="text-xs font-semibold text-slate-900">{command ? "命令下发确认" : "工单创建确认"}</h3><div className="flex flex-wrap gap-1.5"><StatusBadge tone={approvalTone(action.approval_status)}>{approvalLabel(action.approval_status)}</StatusBadge><StatusBadge tone={executionTone(action.execution_status)}>{executionLabel(action.execution_status)}</StatusBadge></div></div>
<p className="mt-2 text-xs leading-5 text-slate-700"><span className="font-semibold text-slate-500">原因:</span>{action.reason}</p> <p className="mt-2 text-xs leading-5 text-slate-700"><span className="font-semibold text-[var(--ink-muted)]">原因:</span>{action.reason}</p>
<ActionPayloadSummary payload={action.payload} /> <ActionPayloadSummary payload={action.payload} />
{editing && !command ? <div className="mt-2 grid gap-2 rounded-lg border border-slate-200 bg-slate-50 p-2.5"> {editing && !command ? <div className="mt-2 grid gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-2.5">
<Input aria-label="工单标题" value={String(draft.title ?? "")} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /> <Input aria-label="工单标题" value={String(draft.title ?? "")} onChange={(event) => setDraft({ ...draft, title: event.target.value })} />
<Input aria-label="工单说明" value={String(draft.description ?? "")} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /> <Input aria-label="工单说明" value={String(draft.description ?? "")} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
<div className="grid grid-cols-2 gap-2"><select aria-label="工单类型" className="min-h-10 rounded-lg border border-slate-200 bg-white px-2 text-xs" value={String(draft.work_order_type ?? "general")} onChange={(event) => setDraft({ ...draft, work_order_type: event.target.value })}><option value="general">一般工单</option><option value="water_quality_complaint">水质投诉</option><option value="burst_report">爆管上报</option></select><select aria-label="工单优先级" className="min-h-10 rounded-lg border border-slate-200 bg-white px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select></div> <div className="grid grid-cols-2 gap-2"><select aria-label="工单类型" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.work_order_type ?? "general")} onChange={(event) => setDraft({ ...draft, work_order_type: event.target.value })}><option value="general">一般工单</option><option value="water_quality_complaint">水质投诉</option><option value="burst_report">爆管上报</option></select><select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select></div>
<Input aria-label="工单位置" value={String(draft.location ?? "")} onChange={(event) => setDraft({ ...draft, location: event.target.value || null })} /> <Input aria-label="工单位置" value={String(draft.location ?? "")} onChange={(event) => setDraft({ ...draft, location: event.target.value || null })} />
<Input aria-label="处理班组" value={String(draft.assignee ?? "")} onChange={(event) => setDraft({ ...draft, assignee: event.target.value || null })} /> <Input aria-label="处理班组" value={String(draft.assignee ?? "")} onChange={(event) => setDraft({ ...draft, assignee: event.target.value || null })} />
<Input aria-label="预约时间" type="datetime-local" value={toLocalDateTime(draft.scheduled_at)} onChange={(event) => setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} /> <Input aria-label="预约时间" type="datetime-local" value={toLocalDateTime(draft.scheduled_at)} onChange={(event) => setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} />
<div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(action.payload); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div> <div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(action.payload); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div>
</div> : null} </div> : null}
{action.evidence_refs.length > 0 ? ( {action.evidence_refs.length > 0 ? (
<div className="mt-2 rounded-lg bg-blue-50/70 px-2.5 py-2 text-xs leading-5 text-slate-700"> <div className="mt-2 rounded-lg bg-[var(--action-soft)]/70 px-2.5 py-2 text-xs leading-5 text-slate-700">
<p className="font-semibold text-blue-700">确认依据</p> <p className="font-semibold text-[var(--action-blue-hover)]">确认依据</p>
<ul className="mt-1 grid gap-1"> <ul className="mt-1 grid gap-1">
{action.evidence_refs.map((evidenceId) => { {action.evidence_refs.map((evidenceId) => {
const evidence = task.evidence.find((item) => item.evidence_id === evidenceId); const evidence = task.evidence.find((item) => item.evidence_id === evidenceId);
@@ -506,12 +507,12 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
</div> </div>
) : null} ) : null}
{action.receipt ? <ActionReceipt receipt={action.receipt} /> : null} {action.receipt ? <ActionReceipt receipt={action.receipt} /> : null}
{action.execution_detail && !action.receipt ? <p className="mt-2 rounded-lg bg-slate-50 px-2.5 py-2 text-xs leading-5 text-slate-700">{action.execution_detail}</p> : null} {action.execution_detail && !action.receipt ? <p className="mt-2 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 text-xs leading-5 text-slate-700">{action.execution_detail}</p> : null}
<Disclosure label="查看原始参数" className="surface-control mt-2 rounded-lg border border-slate-200/70 px-2.5"><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all border-t border-slate-200/70 py-2 font-mono text-[11px] leading-5 text-slate-700">{JSON.stringify(action.payload, null, 2)}</pre></Disclosure> <Disclosure label="查看原始参数" className="surface-control mt-2 rounded-lg border border-[var(--surface-divider)]/70 px-2.5"><pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all border-t border-[var(--surface-divider)]/70 py-2 font-mono text-[11px] leading-5 text-slate-700">{JSON.stringify(action.payload, null, 2)}</pre></Disclosure>
<p className="mt-2 text-[11px] leading-5 text-slate-500">确认版本 v{action.version}。内容或版本变化后必须重新确认。</p> <p className="mt-2 text-[11px] leading-5 text-[var(--ink-muted)]">确认版本 v{action.version}。内容或版本变化后必须重新确认。</p>
{error ? <p role="alert" className="mt-2 text-xs leading-5 text-red-700">{error} 当前状态未按成功处理。</p> : null} {error ? <p role="alert" className="mt-2 text-xs leading-5 text-red-700">{error} 当前状态未按成功处理。</p> : null}
{stale ? <Button variant="outline" size="sm" className="mt-2" onClick={() => { setStale(false); onRefresh(); }}><RefreshCw size={14} />刷新后重新确认</Button> : null} {stale ? <Button variant="outline" size="sm" className="mt-2" onClick={() => { setStale(false); onRefresh(); }}><RefreshCw size={14} />刷新后重新确认</Button> : null}
{!canDecide && action.approval_status === "pending" ? <p className="mt-2 rounded-lg bg-slate-50 px-2.5 py-2 text-xs leading-5 text-slate-600">当前账号仅可查看,需要具备此动作审批权限的人员确认。</p> : null} {!canDecide && action.approval_status === "pending" ? <p className="mt-2 rounded-lg bg-[var(--surface-reading)] px-2.5 py-2 text-xs leading-5 text-slate-600">当前账号仅可查看,需要具备此动作审批权限的人员确认。</p> : null}
{action.approval_status === "pending" && canDecide ? <Input className="mt-2" aria-label="退回理由" placeholder="退回理由,拒绝动作时必填" value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} /> : null} {action.approval_status === "pending" && canDecide ? <Input className="mt-2" aria-label="退回理由" placeholder="退回理由,拒绝动作时必填" value={rejectComment} onChange={(event) => setRejectComment(event.target.value)} /> : null}
<div className="mt-3 flex flex-wrap justify-end gap-2"> <div className="mt-3 flex flex-wrap justify-end gap-2">
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(action.payload); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并下发工单" : "批准创建工单"}</Button></> : null} {action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(action.payload); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并下发工单" : "批准创建工单"}</Button></> : null}
@@ -544,7 +545,7 @@ function ActionPayloadSummary({ payload }: { payload: Record<string, unknown> })
} }
return [{ key, value }]; return [{ key, value }];
}); });
return <dl className="surface-control mt-2 grid gap-1 rounded-lg border border-slate-200/70 px-2.5 py-2 text-xs leading-5">{rows.map((row) => <div key={row.key} className="grid grid-cols-[76px_1fr] gap-2"><dt className="font-semibold text-slate-500">{payloadLabel(row.key)}</dt><dd className="break-words text-slate-800">{payloadValue(row.key, row.value)}</dd></div>)}</dl>; return <dl className="surface-control mt-2 grid gap-1 rounded-lg border border-[var(--surface-divider)]/70 px-2.5 py-2 text-xs leading-5">{rows.map((row) => <div key={row.key} className="grid grid-cols-[76px_1fr] gap-2"><dt className="font-semibold text-[var(--ink-muted)]">{payloadLabel(row.key)}</dt><dd className="break-words text-slate-800">{payloadValue(row.key, row.value)}</dd></div>)}</dl>;
} }
function payloadLabel(key: string) { function payloadLabel(key: string) {
@@ -307,7 +307,7 @@ export function AgentTaskTicker({
key={runningCondition.id} key={runningCondition.id}
className={cn( className={cn(
"block h-1.5 w-1.5 rounded-sm transition-colors duration-150", "block h-1.5 w-1.5 rounded-sm transition-colors duration-150",
index === activeIndicatorIndex ? "bg-blue-600" : "bg-slate-300" index === activeIndicatorIndex ? "bg-[var(--action-blue)]" : "bg-slate-300"
)} )}
/> />
))} ))}
@@ -391,7 +391,7 @@ function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-4"> <div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-4">
<TickerStatusDot progressValue={view.progressValue} /> <TickerStatusDot progressValue={view.progressValue} />
<div className="min-w-0"> <div className="min-w-0">
<span className="block truncate text-[13px] font-semibold leading-5 text-slate-500"> <span className="block truncate text-[13px] font-semibold leading-5 text-[var(--ink-muted)]">
{view.title} {view.title}
</span> </span>
{view.progressValue !== undefined ? ( {view.progressValue !== undefined ? (
@@ -407,7 +407,7 @@ function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
<TickerStatusDot progressValue={view.progressValue} /> <TickerStatusDot progressValue={view.progressValue} />
<div className="flex min-w-0 flex-col justify-center"> <div className="flex min-w-0 flex-col justify-center">
<div className="min-w-0 pr-1"> <div className="min-w-0 pr-1">
<span className="block truncate text-[13px] font-semibold leading-4 text-slate-500"> <span className="block truncate text-[13px] font-semibold leading-4 text-[var(--ink-muted)]">
{view.title} {view.title}
</span> </span>
</div> </div>
@@ -417,7 +417,7 @@ function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
{view.currentStep.title} {view.currentStep.title}
</span> </span>
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" /> <span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-500"> <span className="min-w-0 truncate text-[13px] font-medium leading-5 text-[var(--ink-muted)]">
{view.currentStep.detail} {view.currentStep.detail}
</span> </span>
</div> </div>
@@ -441,7 +441,7 @@ function TickerProgressBar({
<div <div
className={cn( className={cn(
"mt-0.5 h-0.5 overflow-hidden rounded-full", "mt-0.5 h-0.5 overflow-hidden rounded-full",
muted ? "bg-slate-200/60" : "bg-slate-200/80" muted ? "bg-[var(--surface-divider)]/60" : "bg-[var(--surface-divider)]/80"
)} )}
aria-hidden="true" aria-hidden="true"
> >
@@ -101,7 +101,7 @@ export function AnalysisHistorySection({
icon={<Archive size={15} />} icon={<Archive size={15} />}
tone="info" tone="info"
meta={loadedRef.current ? ( meta={loadedRef.current ? (
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-[11px] font-medium tabular-nums text-slate-600"> <span className="rounded-md bg-[var(--surface-control)] px-2 py-0.5 text-[11px] font-medium tabular-nums text-slate-600">
已加载 {items.length} 项 已加载 {items.length} 项
</span> </span>
) : null} ) : null}
@@ -112,10 +112,10 @@ export function AnalysisHistorySection({
if (event.currentTarget.open && !loadedRef.current && !loading) void load(); if (event.currentTarget.open && !loadedRef.current && !loading) void load();
}} }}
> >
<div className="mt-1 border-t border-slate-200 pt-3"> <div className="mt-1 border-t border-[var(--surface-divider)] pt-3">
{error ? <WorkbenchNotice role="alert" tone="danger" className="mb-3">{error}</WorkbenchNotice> : null} {error ? <WorkbenchNotice role="alert" tone="danger" className="mb-3">{error}</WorkbenchNotice> : null}
{loading && !items.length ? ( {loading && !items.length ? (
<div role="status" className="flex min-h-20 items-center justify-center gap-2 text-xs text-slate-500"> <div role="status" className="flex min-h-20 items-center justify-center gap-2 text-xs text-[var(--ink-muted)]">
<LoaderCircle size={15} className="animate-spin" />正在读取分析成果历史 <LoaderCircle size={15} className="animate-spin" />正在读取分析成果历史
</div> </div>
) : null} ) : null}
@@ -135,9 +135,9 @@ export function AnalysisHistorySection({
aria-label={`打开分析成果:${item.name}`} aria-label={`打开分析成果:${item.name}`}
disabled={Boolean(openingRunId)} disabled={Boolean(openingRunId)}
onClick={() => void openArtifact(item)} onClick={() => void openArtifact(item)}
className="group flex min-h-[68px] w-full items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-left shadow-xs transition-[border-color,background-color,scale] duration-150 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] [@media(hover:hover)]:hover:border-blue-200 [@media(hover:hover)]:hover:bg-blue-50/55 active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 disabled:cursor-wait disabled:opacity-60" className="group flex min-h-[68px] w-full items-center gap-3 rounded-xl border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-3 py-2.5 text-left shadow-xs transition-[border-color,background-color,scale] duration-150 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--action-soft)]/55 active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 disabled:cursor-wait disabled:opacity-60"
> >
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700"> <span className="grid size-8 shrink-0 place-items-center rounded-lg bg-[var(--action-soft)] text-[var(--action-blue-hover)]">
{openingRunId === item.runId {openingRunId === item.runId
? <LoaderCircle size={15} className="animate-spin" /> ? <LoaderCircle size={15} className="animate-spin" />
: <BarChart3 size={15} />} : <BarChart3 size={15} />}
@@ -147,14 +147,14 @@ export function AnalysisHistorySection({
<span className="truncate text-xs font-semibold text-slate-900">{item.name}</span> <span className="truncate text-xs font-semibold text-slate-900">{item.name}</span>
<StatusBadge tone={statusTone(item.status)}>{item.statusLabel}</StatusBadge> <StatusBadge tone={statusTone(item.status)}>{item.statusLabel}</StatusBadge>
</span> </span>
<span className="mt-1 block truncate text-[11px] leading-4 text-slate-500"> <span className="mt-1 block truncate text-[11px] leading-4 text-[var(--ink-muted)]">
{item.runTypeLabel} · {formatHistoryTime(item.createdAt)} · {item.createdBy} {item.runTypeLabel} · {formatHistoryTime(item.createdAt)} · {item.createdBy}
</span> </span>
<span className="mt-0.5 block text-[11px] leading-4 text-slate-400"> <span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]">
{item.hasResults ? "已有持久化结果" : "仅有运行记录"} {item.hasResults ? "已有持久化结果" : "仅有运行记录"}
</span> </span>
</span> </span>
<ChevronRight size={14} className="shrink-0 text-slate-400 transition-colors group-hover:text-blue-600" /> <ChevronRight size={14} className="shrink-0 text-[var(--ink-muted)] transition-colors group-hover:text-[var(--action-blue-hover)]" />
</button> </button>
</li> </li>
))} ))}
@@ -22,13 +22,13 @@ export function AnalysisResultsPanel({
aria-label="项目分析成果" aria-label="项目分析成果"
className="acrylic-panel absolute bottom-3 right-3 top-16 z-[60] flex w-[min(560px,calc(100%-1.5rem))] flex-col overflow-hidden rounded-2xl border text-slate-900 shadow-[0_16px_44px_rgba(15,23,42,0.18)]" className="acrylic-panel absolute bottom-3 right-3 top-16 z-[60] flex w-[min(560px,calc(100%-1.5rem))] flex-col overflow-hidden rounded-2xl border text-slate-900 shadow-[0_16px_44px_rgba(15,23,42,0.18)]"
> >
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-slate-200/80 px-4"> <header className="flex h-14 shrink-0 items-center gap-3 border-b border-[var(--surface-divider)]/80 px-4">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-blue-600 text-white"> <span className="grid h-8 w-8 place-items-center rounded-lg bg-[var(--action-blue)] text-white">
<BarChart3 size={17} aria-hidden="true" /> <BarChart3 size={17} aria-hidden="true" />
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">项目分析成果</h2> <h2 className="text-sm font-semibold">项目分析成果</h2>
<p className="truncate text-xs text-slate-500">查看已写入当前项目 BizDB 的分析结果</p> <p className="truncate text-xs text-[var(--ink-muted)]">查看已写入当前项目 BizDB 的分析结果</p>
</div> </div>
<Button size="icon" variant="ghost" aria-label="关闭分析成果面板" onClick={onClose}> <Button size="icon" variant="ghost" aria-label="关闭分析成果面板" onClick={onClose}>
<X size={17} /> <X size={17} />
@@ -31,10 +31,10 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
return ( return (
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border"> <aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
<div className="border-b border-slate-200/80 p-4"> <div className="border-b border-[var(--surface-divider)]/80 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<p className="text-xs font-medium tracking-[0.14em] text-slate-500">要素详情</p> <p className="text-xs font-medium tracking-[0.14em] text-[var(--ink-muted)]">要素详情</p>
<h2 className="mt-1 text-lg font-semibold text-slate-950"> <h2 className="mt-1 text-lg font-semibold text-slate-950">
{feature ? feature.title : "选择一个管网对象"} {feature ? feature.title : "选择一个管网对象"}
</h2> </h2>
@@ -44,7 +44,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
aria-label="关闭详情" aria-label="关闭详情"
title="关闭详情" title="关闭详情"
onClick={onClose} onClick={onClose}
className="surface-control grid h-9 w-9 place-items-center rounded border text-slate-500 hover:text-slate-900" className="surface-control grid h-9 w-9 place-items-center rounded border text-[var(--ink-muted)] hover:text-slate-900"
> >
<X size={16} aria-hidden="true" /> <X size={16} aria-hidden="true" />
</button> </button>
@@ -60,14 +60,14 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{metrics.map(({ label, value }) => ( {metrics.map(({ label, value }) => (
<div key={label} className="surface-reading rounded border p-3"> <div key={label} className="surface-reading rounded border p-3">
<p className="text-xs text-slate-500">{label}</p> <p className="text-xs text-[var(--ink-muted)]">{label}</p>
<p className="mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums">{value}</p> <p className="mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums">{value}</p>
</div> </div>
))} ))}
</div> </div>
<section className="material-tone-agent mt-4 rounded border border-violet-100 p-3"> <section className="material-tone-agent mt-4 rounded border border-[var(--surface-border)] p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-violet-800"> <div className="flex items-center gap-2 text-sm font-semibold text-[var(--action-blue-pressed)]">
<TriangleAlert size={16} aria-hidden="true" /> <TriangleAlert size={16} aria-hidden="true" />
Agent 解释 Agent 解释
</div> </div>
@@ -85,11 +85,11 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
</> </>
) : ( ) : (
<div className="flex h-full flex-col items-center justify-center text-center"> <div className="flex h-full flex-col items-center justify-center text-center">
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-slate-500"> <div className="surface-reading grid h-12 w-12 place-items-center rounded border text-[var(--ink-muted)]">
<Target size={22} aria-hidden="true" /> <Target size={22} aria-hidden="true" />
</div> </div>
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p> <p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
<p className="mt-1 max-w-[260px] text-sm leading-6 text-slate-500"> <p className="mt-1 max-w-[260px] text-sm leading-6 text-[var(--ink-muted)]">
选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。 选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。
</p> </p>
</div> </div>
@@ -51,10 +51,10 @@ const FEATURE_LAYER_META: Partial<Record<
} }
>> = { >> = {
pipes: { icon: PipeFeatureIcon, label: "管线", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }, pipes: { icon: PipeFeatureIcon, label: "管线", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
junctions: { icon: JunctionFeatureIcon, label: "节点", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" }, junctions: { icon: JunctionFeatureIcon, label: "节点", headerClassName: "bg-[var(--action-soft)]/70", labelClassName: "text-[var(--action-blue-pressed)]", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
valves: { icon: ValveFeatureIcon, label: "阀门", headerClassName: "bg-violet-50/70", labelClassName: "text-violet-800", iconClassName: "bg-violet-600 text-white ring-violet-900/10" }, valves: { icon: ValveFeatureIcon, label: "阀门", headerClassName: "bg-[var(--action-soft)]/70", labelClassName: "text-[var(--action-blue-pressed)]", iconClassName: "bg-[var(--action-blue)] text-white ring-[var(--action-blue)]/10" },
reservoirs: { icon: ReservoirFeatureIcon, label: "水库", headerClassName: "bg-sky-50/70", labelClassName: "text-sky-900", iconClassName: "bg-sky-800 text-white ring-sky-900/10" }, reservoirs: { icon: ReservoirFeatureIcon, label: "水库", headerClassName: "bg-sky-50/70", labelClassName: "text-sky-900", iconClassName: "bg-sky-800 text-white ring-sky-900/10" },
scada: { icon: ScadaFeatureIcon, label: "SCADA", headerClassName: "bg-slate-50/80", labelClassName: "text-slate-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" }, scada: { icon: ScadaFeatureIcon, label: "SCADA", headerClassName: "bg-[var(--surface-reading)]/80", labelClassName: "text-slate-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50/70", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-900/10" }, pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50/70", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-900/10" },
tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }, tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
water_quality_monitoring_points: { icon: ScadaFeatureIcon, label: "水质监测点", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" } water_quality_monitoring_points: { icon: ScadaFeatureIcon, label: "水质监测点", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }
@@ -63,7 +63,7 @@ const FEATURE_LAYER_META: Partial<Record<
const SOURCE_GIS_LAYER_META = { const SOURCE_GIS_LAYER_META = {
icon: JunctionFeatureIcon, icon: JunctionFeatureIcon,
label: "GIS 交付要素", label: "GIS 交付要素",
headerClassName: "bg-slate-50/80", headerClassName: "bg-[var(--surface-reading)]/80",
labelClassName: "text-slate-700", labelClassName: "text-slate-700",
iconClassName: "bg-slate-600 text-white ring-slate-900/10" iconClassName: "bg-slate-600 text-white ring-slate-900/10"
}; };
@@ -164,12 +164,12 @@ export function FeaturePopover({
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{canCopy ? ( {canCopy ? (
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950"> <button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-[var(--ink-muted)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/70 [@media(hover:hover)]:hover:text-slate-950">
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} /> <Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
<Check size={16} aria-hidden="true" className={cn("status-tone-success absolute text-[var(--status-foreground)] transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} /> <Check size={16} aria-hidden="true" className={cn("status-tone-success absolute text-[var(--status-foreground)] transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
</button> </button>
) : null} ) : null}
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950"> <button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-[var(--ink-muted)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/70 [@media(hover:hover)]:hover:text-slate-950">
<X size={16} aria-hidden="true" /> <X size={16} aria-hidden="true" />
</button> </button>
</div> </div>
@@ -179,20 +179,20 @@ export function FeaturePopover({
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-3"> <div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-3">
{panelModel.attributes.length > 0 ? ( {panelModel.attributes.length > 0 ? (
<> <>
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500"> <div className="mb-2 flex items-center gap-2 text-xs font-semibold text-[var(--ink-muted)]">
<Database size={14} aria-hidden="true" /> <Database size={14} aria-hidden="true" />
设施属性 设施属性
</div> </div>
<FeaturePropertyList entries={panelModel.attributes} variant="popover" /> <FeaturePropertyList entries={panelModel.attributes} variant="popover" />
</> </>
) : ( ) : (
<p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p> <p className="py-3 text-center text-sm text-[var(--ink-muted)]">暂无可展示属性</p>
)} )}
{gisProperties.groups.length > 0 || gisProperties.status === "error" ? ( {gisProperties.groups.length > 0 || gisProperties.status === "error" ? (
<Disclosure <Disclosure
key={featureKey} key={featureKey}
className="mt-2 border-t border-slate-100 pt-1" className="mt-2 border-t border-[var(--surface-divider)] pt-1"
onToggle={(event) => { onToggle={(event) => {
if (event.target === event.currentTarget) { if (event.target === event.currentTarget) {
setExpandedGisFeatureKey(event.currentTarget.open ? featureKey : null); setExpandedGisFeatureKey(event.currentTarget.open ? featureKey : null);
@@ -201,7 +201,7 @@ export function FeaturePopover({
label={( label={(
<span className="flex min-w-0 flex-1 items-center justify-between gap-3"> <span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>GIS 台账属性</span> <span>GIS 台账属性</span>
<span className="shrink-0 text-[11px] font-normal text-slate-400"> <span className="shrink-0 text-[11px] font-normal text-[var(--ink-muted)]">
{gisProperties.status === "error" {gisProperties.status === "error"
? "读取失败" ? "读取失败"
: `${gisProperties.groups.length} 份`} : `${gisProperties.groups.length} 份`}
@@ -217,30 +217,30 @@ export function FeaturePopover({
return ( return (
<Disclosure <Disclosure
key={`${group.layer_key}:${group.source_fid}`} key={`${group.layer_key}:${group.source_fid}`}
className="border-b border-slate-100 last:border-b-0" className="border-b border-[var(--surface-divider)] last:border-b-0"
label={( label={(
<span className="min-w-0"> <span className="min-w-0">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span className="truncate text-slate-700">{group.layer_name}</span> <span className="truncate text-slate-700">{group.layer_name}</span>
<span className="shrink-0 rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500"> <span className="shrink-0 rounded-full bg-[var(--surface-control)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--ink-muted)]">
{presentation.datasetRole} {presentation.datasetRole}
</span> </span>
</span> </span>
<span className="block text-[11px] font-normal text-slate-400 tabular-nums"> <span className="block text-[11px] font-normal text-[var(--ink-muted)] tabular-nums">
{group.source_date} · {presentation.usefulCount} 个有效字段 {group.source_date} · {presentation.usefulCount} 个有效字段
</span> </span>
</span> </span>
)} )}
> >
<div className="space-y-2 pb-3 pl-6"> <div className="space-y-2 pb-3 pl-6">
<p className="text-[11px] leading-5 text-slate-500"> <p className="text-[11px] leading-5 text-[var(--ink-muted)]">
{presentation.description} {presentation.description}
</p> </p>
{presentation.summary.length > 0 ? ( {presentation.summary.length > 0 ? (
<dl className="grid grid-cols-2 gap-1.5 sm:grid-cols-3"> <dl className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
{presentation.summary.map((entry) => ( {presentation.summary.map((entry) => (
<div key={entry.key} className="min-w-0 rounded-lg border border-slate-100 bg-white/70 px-2.5 py-2"> <div key={entry.key} className="min-w-0 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/70 px-2.5 py-2">
<dt className="truncate text-[10px] font-medium text-slate-400">{entry.label}</dt> <dt className="truncate text-[10px] font-medium text-[var(--ink-muted)]">{entry.label}</dt>
<dd className="mt-0.5 truncate text-xs font-semibold text-slate-800" title={entry.value}>{entry.value}</dd> <dd className="mt-0.5 truncate text-xs font-semibold text-slate-800" title={entry.value}>{entry.value}</dd>
</div> </div>
))} ))}
@@ -249,7 +249,7 @@ export function FeaturePopover({
{presentation.sections.map((section) => ( {presentation.sections.map((section) => (
<Disclosure <Disclosure
key={section.id} key={section.id}
className="border-t border-slate-100" className="border-t border-[var(--surface-divider)]"
label={`${section.label} · ${section.entries.length} 项`} label={`${section.label} · ${section.entries.length} 项`}
> >
<div className="pb-2 pl-6"> <div className="pb-2 pl-6">
@@ -258,11 +258,11 @@ export function FeaturePopover({
</Disclosure> </Disclosure>
))} ))}
<Disclosure <Disclosure
className="border-t border-slate-100" className="border-t border-[var(--surface-divider)]"
label={`全部原始字段 · ${presentation.rawCount} 项`} label={`全部原始字段 · ${presentation.rawCount} 项`}
> >
<div className="pb-2 pl-6"> <div className="pb-2 pl-6">
<p className="pb-2 text-[11px] leading-5 text-slate-400"> <p className="pb-2 text-[11px] leading-5 text-[var(--ink-muted)]">
包含源系统编码、空值和兼容字段,仅用于核对原始数据。 包含源系统编码、空值和兼容字段,仅用于核对原始数据。
</p> </p>
<FeaturePropertyList entries={presentation.rawEntries} variant="popover" /> <FeaturePropertyList entries={presentation.rawEntries} variant="popover" />
@@ -11,12 +11,12 @@ export function FeaturePropertyList({
variant = "insight" variant = "insight"
}: FeaturePropertyListProps) { }: FeaturePropertyListProps) {
if (entries.length === 0) { if (entries.length === 0) {
return <p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>; return <p className="py-3 text-center text-sm text-[var(--ink-muted)]">暂无可展示属性</p>;
} }
return ( return (
<dl className={cn( <dl className={cn(
"surface-reading divide-y divide-slate-100 border", "surface-reading divide-y divide-[var(--surface-divider)] border",
variant === "popover" ? "rounded-xl px-3" : "rounded" variant === "popover" ? "rounded-xl px-3" : "rounded"
)}> )}>
{entries.map((entry) => ( {entries.map((entry) => (
@@ -30,7 +30,7 @@ export function FeaturePropertyList({
)} )}
> >
<dt className={cn( <dt className={cn(
"text-slate-500", "text-[var(--ink-muted)]",
variant === "popover" ? "text-xs font-medium leading-5" : "truncate" variant === "popover" ? "text-xs font-medium leading-5" : "truncate"
)}> )}>
{entry.label} {entry.label}
@@ -40,7 +40,7 @@ export function FeaturePropertyList({
variant === "popover" variant === "popover"
? "break-words text-right text-sm font-semibold leading-5 tabular-nums" ? "break-words text-right text-sm font-semibold leading-5 tabular-nums"
: "truncate", : "truncate",
entry.value === "暂无" && "font-medium text-slate-400" entry.value === "暂无" && "font-medium text-[var(--ink-muted)]"
)}> )}>
{entry.value} {entry.value}
</dd> </dd>
@@ -15,7 +15,7 @@ export function IconButton({ label, children, onClick, active = false }: IconBut
className={`grid h-10 w-10 place-items-center rounded border transition ${ className={`grid h-10 w-10 place-items-center rounded border transition ${
active active
? "border-slate-800 bg-slate-900 text-white" ? "border-slate-800 bg-slate-900 text-white"
: "surface-control border-transparent text-slate-700 hover:bg-white" : "surface-control border-transparent text-slate-700 hover:bg-[var(--surface-reading)]"
}`} }`}
> >
{children} {children}
@@ -234,10 +234,10 @@ export function MapDevPanel({
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border lg:flex" className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel" data-testid="map-dev-panel"
> >
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200/70 px-4"> <div className="flex h-12 shrink-0 items-center justify-between border-b border-[var(--surface-divider)]/70 px-4">
<div> <div>
<h2 className="text-sm font-semibold text-slate-950">地图能力 Dev Panel</h2> <h2 className="text-sm font-semibold text-slate-950">地图能力 Dev Panel</h2>
<p className="text-[11px] text-slate-500">仅本次页面会话生效</p> <p className="text-[11px] text-[var(--ink-muted)]">仅本次页面会话生效</p>
</div> </div>
<Button <Button
size="icon" size="icon"
@@ -519,11 +519,11 @@ export function MapDevPanel({
) : null} ) : null}
</> </>
)} )}
<div className="space-y-2.5 rounded-xl bg-slate-100/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]"> <div className="space-y-2.5 rounded-xl bg-[var(--surface-control)]/75 p-3 shadow-[inset_0_0_0_1px_rgba(148,163,184,0.18)]">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4> <h4 className="text-[11px] font-semibold text-slate-800">整层标注</h4>
<p className="mt-0.5 text-[10px] leading-4 text-slate-500"> <p className="mt-0.5 text-[10px] leading-4 text-[var(--ink-muted)]">
作用于当前图层所有可见 Feature,自动避让重叠。 作用于当前图层所有可见 Feature,自动避让重叠。
</p> </p>
</div> </div>
@@ -623,7 +623,7 @@ export function MapDevPanel({
</PanelSection> </PanelSection>
</div> </div>
<div className="shrink-0 border-t border-slate-200/70 p-3"> <div className="shrink-0 border-t border-[var(--surface-divider)]/70 p-3">
<Button <Button
type="button" type="button"
onClick={() => commands.resetAll()} onClick={() => commands.resetAll()}
@@ -648,10 +648,10 @@ function PanelSection({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="space-y-2.5 border-b border-slate-200/70 px-1 pb-4 last:border-b-0 last:pb-1"> <section className="space-y-2.5 border-b border-[var(--surface-divider)]/70 px-1 pb-4 last:border-b-0 last:pb-1">
<div> <div>
<h3 className="text-xs font-semibold text-slate-900">{title}</h3> <h3 className="text-xs font-semibold text-slate-900">{title}</h3>
<p className="mt-0.5 text-[11px] leading-4 text-slate-500">{description}</p> <p className="mt-0.5 text-[11px] leading-4 text-[var(--ink-muted)]">{description}</p>
</div> </div>
{children} {children}
</section> </section>
@@ -677,7 +677,7 @@ function ToggleField({
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
}) { }) {
return ( return (
<label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-white/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]"> <label className="flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-[var(--surface-reading)]/80 px-2.5 text-[11px] font-medium text-slate-700 shadow-[inset_0_0_0_1px_rgba(203,213,225,0.9)]">
<Checkbox <Checkbox
checked={checked} checked={checked}
onChange={(event) => onChange(event.target.checked)} onChange={(event) => onChange(event.target.checked)}
@@ -699,7 +699,7 @@ function ColorField({
}) { }) {
return ( return (
<Field label={label}> <Field label={label}>
<div className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white/80 px-2"> <div className="flex h-9 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2">
<input <input
type="color" type="color"
value={value} value={value}
@@ -737,7 +737,7 @@ function RangeField({
step={step} step={step}
value={value} value={value}
onChange={(event) => onChange(Number(event.target.value))} onChange={(event) => onChange(Number(event.target.value))}
className="h-6 w-full accent-blue-600" className="h-6 w-full accent-[var(--action-blue)]"
/> />
</Field> </Field>
); );
@@ -755,4 +755,4 @@ function StatusText({ children }: { children: React.ReactNode }) {
} }
const inputClassName = const inputClassName =
"h-10 w-full rounded-lg border border-slate-200 bg-white/80 px-2.5 text-xs text-slate-800 outline-hidden transition focus:border-blue-400 focus:ring-2 focus:ring-blue-500/15"; "h-10 w-full rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 text-xs text-slate-800 transition-colors";
@@ -160,7 +160,7 @@ export function MobileWorkbenchSheet({
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
> >
<span className="h-1 w-10 rounded-full bg-slate-400/70 transition-[width,background-color] group-focus-visible:w-12 group-focus-visible:bg-blue-600" /> <span className="h-1 w-10 rounded-full bg-slate-400/70 transition-[width,background-color] group-focus-visible:w-12 group-focus-visible:bg-[var(--action-blue)]" />
</button> </button>
</div> </div>
<div className="min-h-0 flex-1 overscroll-contain">{children}</div> <div className="min-h-0 flex-1 overscroll-contain">{children}</div>
@@ -59,9 +59,9 @@ import {
import { getConditionReportRiskLevel } from "./condition-report-risk"; import { getConditionReportRiskLevel } from "./condition-report-risk";
const RUNNING_WORKFLOW_TICK_MS = 1_000; const RUNNING_WORKFLOW_TICK_MS = 1_000;
const NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-slate-200/70"; const NEUTRAL_SURFACE_CLASS_NAME = "surface-reading rounded-xl border border-[var(--surface-divider)]/70";
const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-slate-200/70"; const INSET_SURFACE_CLASS_NAME = "surface-control rounded-xl border border-[var(--surface-divider)]/70";
const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-slate-200/70 px-2 py-1.5"; const REPORT_META_TILE_CLASS_NAME = "surface-control rounded-lg border border-[var(--surface-divider)]/70 px-2 py-1.5";
const riskTones = { const riskTones = {
normal: "success", normal: "success",
@@ -283,7 +283,7 @@ function ConditionRecordDetailPanel({
) : ( ) : (
<DetailSectionCard> <DetailSectionCard>
<SectionTitle icon={ClipboardList} title="工单报告" /> <SectionTitle icon={ClipboardList} title="工单报告" />
<p className="mt-2 text-xs leading-5 text-slate-500">尚未同步到工单详情,请刷新工况任务后查看。</p> <p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">尚未同步到工单详情,请刷新工况任务后查看。</p>
</DetailSectionCard> </DetailSectionCard>
) )
) : condition.operationalTask ? ( ) : condition.operationalTask ? (
@@ -486,7 +486,7 @@ function WorkOrderDetailPanel({ condition }: { condition: ScheduledWorkOrderItem
<DetailHeader <DetailHeader
condition={condition} condition={condition}
icon={ClipboardList} icon={ClipboardList}
iconClassName="bg-blue-50 text-blue-700" iconClassName="bg-[var(--action-soft)] text-[var(--action-blue-hover)]"
hideDetail hideDetail
/> />
<DetailDecisionBody> <DetailDecisionBody>
@@ -538,7 +538,7 @@ function getConditionDetailHeader(condition: ScheduledConditionRecord) {
if (condition.status === "running") { if (condition.status === "running") {
return { return {
icon: Loader2, icon: Loader2,
iconClassName: "bg-blue-600 text-white", iconClassName: "bg-[var(--action-blue)] text-white",
iconSpin: true iconSpin: true
}; };
} }
@@ -601,7 +601,7 @@ function DetailHeader({
<div className="flex min-w-0 items-start gap-2.5"> <div className="flex min-w-0 items-start gap-2.5">
<span <span
className={cn( className={cn(
"grid h-9 w-9 shrink-0 place-items-center bg-slate-100 text-slate-600", "grid h-9 w-9 shrink-0 place-items-center bg-[var(--surface-control)] text-slate-600",
"rounded-lg", "rounded-lg",
iconClassName iconClassName
)} )}
@@ -610,7 +610,7 @@ function DetailHeader({
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-semibold text-slate-500">{formatTime(condition.scheduledAt)}</span> <span className="font-mono text-xs font-semibold text-[var(--ink-muted)]">{formatTime(condition.scheduledAt)}</span>
{condition.kind === "work_order" || condition.status === "running" || condition.status === "pending" ? ( {condition.kind === "work_order" || condition.status === "running" || condition.status === "pending" ? (
<StatusPill condition={condition} /> <StatusPill condition={condition} />
) : null} ) : null}
@@ -671,26 +671,26 @@ function SolutionOptionsList({
return ( return (
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}> <section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}>
<SectionEyebrow icon={Route} label="演示处置方案" /> <SectionEyebrow icon={Route} label="演示处置方案" />
<div className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-slate-200/70 overflow-hidden")}> <div className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-[var(--surface-divider)]/70 overflow-hidden")}>
{options.map((option, index) => ( {options.map((option, index) => (
<article key={option.id} className="px-2.5 py-2"> <article key={option.id} className="px-2.5 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="surface-reading grid h-5 w-5 shrink-0 place-items-center rounded-lg border border-slate-200/70 text-xs font-semibold text-slate-600"> <span className="surface-reading grid h-5 w-5 shrink-0 place-items-center rounded-lg border border-[var(--surface-divider)]/70 text-xs font-semibold text-slate-600">
{index + 1} {index + 1}
</span> </span>
<h4 className="min-w-0 truncate text-xs font-semibold leading-4 text-slate-900">{option.title}</h4> <h4 className="min-w-0 truncate text-xs font-semibold leading-4 text-slate-900">{option.title}</h4>
</div> </div>
<dl className="mt-2 grid gap-1.5 text-xs leading-5"> <dl className="mt-2 grid gap-1.5 text-xs leading-5">
<div className="grid grid-cols-[56px_1fr] gap-2"> <div className="grid grid-cols-[56px_1fr] gap-2">
<dt className="font-semibold text-slate-400">适用</dt> <dt className="font-semibold text-[var(--ink-muted)]">适用</dt>
<dd className="text-slate-700">{option.scenario}</dd> <dd className="text-slate-700">{option.scenario}</dd>
</div> </div>
<div className="grid grid-cols-[56px_1fr] gap-2"> <div className="grid grid-cols-[56px_1fr] gap-2">
<dt className="font-semibold text-slate-400">动作</dt> <dt className="font-semibold text-[var(--ink-muted)]">动作</dt>
<dd className="text-slate-700">{option.action}</dd> <dd className="text-slate-700">{option.action}</dd>
</div> </div>
<div className="grid grid-cols-[56px_1fr] gap-2"> <div className="grid grid-cols-[56px_1fr] gap-2">
<dt className="font-semibold text-slate-400">代价</dt> <dt className="font-semibold text-[var(--ink-muted)]">代价</dt>
<dd className="text-slate-700">{option.tradeoff}</dd> <dd className="text-slate-700">{option.tradeoff}</dd>
</div> </div>
</dl> </dl>
@@ -755,7 +755,7 @@ function IncidentWorkOrderPanel({
"rounded-xl", "rounded-xl",
submitted submitted
? "status-tone-success cursor-not-allowed bg-[var(--status-soft)] text-[var(--status-foreground)]" ? "status-tone-success cursor-not-allowed bg-[var(--status-soft)] text-[var(--status-foreground)]"
: "bg-blue-600 text-white hover:bg-blue-700" : "bg-[var(--action-blue)] text-white hover:bg-[var(--action-blue-hover)]"
)} )}
> >
{submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />} {submitted ? <CheckCircle2 size={14} aria-hidden="true" /> : <Send size={14} aria-hidden="true" />}
@@ -773,7 +773,7 @@ function IncidentOperationList({
submitted: boolean; submitted: boolean;
}) { }) {
return ( return (
<div className={cn(INSET_SURFACE_CLASS_NAME, "divide-y divide-slate-200/70 overflow-hidden")}> <div className={cn(INSET_SURFACE_CLASS_NAME, "divide-y divide-[var(--surface-divider)]/70 overflow-hidden")}>
{operations.map((operation) => ( {operations.map((operation) => (
<article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2"> <article key={`${operation.target}-${operation.action}`} className="px-2.5 py-2">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
@@ -784,15 +784,15 @@ function IncidentOperationList({
</div> </div>
<dl className="mt-1.5 grid gap-1 text-xs leading-5"> <dl className="mt-1.5 grid gap-1 text-xs leading-5">
<div className="grid grid-cols-[56px_1fr] gap-2"> <div className="grid grid-cols-[56px_1fr] gap-2">
<dt className="font-semibold text-slate-400">对象</dt> <dt className="font-semibold text-[var(--ink-muted)]">对象</dt>
<dd className="text-slate-700">{operation.target}</dd> <dd className="text-slate-700">{operation.target}</dd>
</div> </div>
<div className="grid grid-cols-[56px_1fr] gap-2"> <div className="grid grid-cols-[56px_1fr] gap-2">
<dt className="font-semibold text-slate-400">指令</dt> <dt className="font-semibold text-[var(--ink-muted)]">指令</dt>
<dd className="text-slate-700">{operation.command}</dd> <dd className="text-slate-700">{operation.command}</dd>
</div> </div>
<div className="grid grid-cols-[56px_1fr] gap-2"> <div className="grid grid-cols-[56px_1fr] gap-2">
<dt className="font-semibold text-slate-400">复核</dt> <dt className="font-semibold text-[var(--ink-muted)]">复核</dt>
<dd className="text-slate-700">{operation.verification}</dd> <dd className="text-slate-700">{operation.verification}</dd>
</div> </div>
</dl> </dl>
@@ -806,10 +806,10 @@ function EscalationCriteriaList({ items }: { items: string[] }) {
return ( return (
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}> <section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "px-3 py-3")}>
<SectionEyebrow icon={AlertTriangle} label="升级条件" /> <SectionEyebrow icon={AlertTriangle} label="升级条件" />
<ol className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-slate-200/70 overflow-hidden")}> <ol className={cn(INSET_SURFACE_CLASS_NAME, "mt-2 divide-y divide-[var(--surface-divider)]/70 overflow-hidden")}>
{items.map((item, index) => ( {items.map((item, index) => (
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 px-2 py-1.5 text-xs leading-5 text-slate-700"> <li key={item} className="grid grid-cols-[18px_1fr] gap-2 px-2 py-1.5 text-xs leading-5 text-slate-700">
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-slate-200/70 text-xs font-semibold text-slate-600"> <span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-[var(--surface-divider)]/70 text-xs font-semibold text-slate-600">
{index + 1} {index + 1}
</span> </span>
<span>{item}</span> <span>{item}</span>
@@ -837,7 +837,7 @@ function ConditionStatusReport({
return ( return (
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}> <section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
<div className="border-b border-slate-200/70 px-3 py-3"> <div className="border-b border-[var(--surface-divider)]/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<SectionTitle icon={FileText} title="完成状态报告" /> <SectionTitle icon={FileText} title="完成状态报告" />
<LifecyclePill /> <LifecyclePill />
@@ -849,7 +849,7 @@ function ConditionStatusReport({
<RiskBadge riskLevel={riskLevel} /> <RiskBadge riskLevel={riskLevel} />
</div> </div>
) : null} ) : null}
<div className="mt-2 flex items-center gap-1.5 text-xs leading-5 text-slate-500"> <div className="mt-2 flex items-center gap-1.5 text-xs leading-5 text-[var(--ink-muted)]">
<Clock3 size={13} aria-hidden="true" /> <Clock3 size={13} aria-hidden="true" />
<span>报告时段</span> <span>报告时段</span>
<span className="font-mono font-semibold tabular-nums text-slate-700">{statusTime}</span> <span className="font-mono font-semibold tabular-nums text-slate-700">{statusTime}</span>
@@ -868,7 +868,7 @@ function ConditionStatusReport({
) : null} ) : null}
{report?.sections?.length ? ( {report?.sections?.length ? (
<div className="grid gap-2 border-t border-slate-200/70 px-3 py-3"> <div className="grid gap-2 border-t border-[var(--surface-divider)]/70 px-3 py-3">
{report.sections.map((section) => ( {report.sections.map((section) => (
<ReportSection key={section.title} title={section.title} items={section.items} /> <ReportSection key={section.title} title={section.title} items={section.items} />
))} ))}
@@ -904,7 +904,7 @@ function RiskBadge({ riskLevel }: { riskLevel: ScheduledConditionRiskLevel }) {
function ReportMetaTile({ label, value }: { label: string; value: string }) { function ReportMetaTile({ label, value }: { label: string; value: string }) {
return ( return (
<div className={REPORT_META_TILE_CLASS_NAME}> <div className={REPORT_META_TILE_CLASS_NAME}>
<div className="text-xs font-semibold text-slate-400">{label}</div> <div className="text-xs font-semibold text-[var(--ink-muted)]">{label}</div>
<div className="mt-0.5 truncate text-xs font-semibold text-slate-700" title={value}> <div className="mt-0.5 truncate text-xs font-semibold text-slate-700" title={value}>
{value} {value}
</div> </div>
@@ -921,7 +921,7 @@ function ReportSection({
}) { }) {
return ( return (
<section className={cn(INSET_SURFACE_CLASS_NAME, "px-2.5 py-2")}> <section className={cn(INSET_SURFACE_CLASS_NAME, "px-2.5 py-2")}>
<div className="text-xs font-semibold text-slate-500">{title}</div> <div className="text-xs font-semibold text-[var(--ink-muted)]">{title}</div>
<ul className="mt-1.5 grid gap-1"> <ul className="mt-1.5 grid gap-1">
{items.map((item) => ( {items.map((item) => (
<li key={item} className="grid grid-cols-[7px_1fr] gap-2 text-xs leading-5 text-slate-700"> <li key={item} className="grid grid-cols-[7px_1fr] gap-2 text-xs leading-5 text-slate-700">
@@ -936,15 +936,15 @@ function ReportSection({
function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) { function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
return ( return (
<div className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2"> <div className="surface-reading min-w-0 rounded-xl border border-[var(--surface-divider)]/70 px-2.5 py-2">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-xs font-semibold text-slate-500" title={kpi.label}> <div className="truncate text-xs font-semibold text-[var(--ink-muted)]" title={kpi.label}>
{kpi.label} {kpi.label}
</div> </div>
<div className="mt-1 flex items-baseline gap-1"> <div className="mt-1 flex items-baseline gap-1">
<span className="font-mono text-lg font-semibold leading-5 text-slate-900">{kpi.value}</span> <span className="font-mono text-lg font-semibold leading-5 text-slate-900">{kpi.value}</span>
{kpi.unit ? <span className="text-xs font-semibold text-slate-500">{kpi.unit}</span> : null} {kpi.unit ? <span className="text-xs font-semibold text-[var(--ink-muted)]">{kpi.unit}</span> : null}
</div> </div>
</div> </div>
<StatusBadge tone={riskTones[kpi.status]}> <StatusBadge tone={riskTones[kpi.status]}>
@@ -953,7 +953,7 @@ function KpiTile({ kpi }: { kpi: ScheduledConditionKpi }) {
</div> </div>
<p className="mt-1.5 text-xs leading-4 text-slate-600">{kpi.description}</p> <p className="mt-1.5 text-xs leading-4 text-slate-600">{kpi.description}</p>
{kpi.threshold || kpi.baseline ? ( {kpi.threshold || kpi.baseline ? (
<div className="mt-1.5 space-y-0.5 text-xs leading-4 text-slate-500"> <div className="mt-1.5 space-y-0.5 text-xs leading-4 text-[var(--ink-muted)]">
{kpi.threshold ? <div>阈值:{kpi.threshold}</div> : null} {kpi.threshold ? <div>阈值:{kpi.threshold}</div> : null}
{kpi.baseline ? <div>基线:{kpi.baseline}</div> : null} {kpi.baseline ? <div>基线:{kpi.baseline}</div> : null}
</div> </div>
@@ -995,8 +995,8 @@ function DecisionActionStrip({
"flex min-h-9 shrink-0 items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors min-[1500px]:w-[104px]", "flex min-h-9 shrink-0 items-center justify-center gap-1.5 px-3 text-xs font-semibold leading-4 transition-colors min-[1500px]:w-[104px]",
"rounded-xl", "rounded-xl",
actionDisabled actionDisabled
? "surface-control cursor-not-allowed border border-slate-200/70 text-slate-500 opacity-80" ? "surface-control cursor-not-allowed border border-[var(--surface-divider)]/70 text-[var(--ink-muted)] opacity-80"
: "bg-blue-600 text-white hover:bg-blue-700" : "bg-[var(--action-blue)] text-white hover:bg-[var(--action-blue-hover)]"
)} )}
> >
<MessageSquare size={14} aria-hidden="true" /> <MessageSquare size={14} aria-hidden="true" />
@@ -1014,7 +1014,7 @@ function SectionEyebrow({
label: string; label: string;
}) { }) {
return ( return (
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-500"> <div className="flex items-center gap-1.5 text-xs font-semibold text-[var(--ink-muted)]">
{Icon ? <Icon size={13} aria-hidden="true" /> : null} {Icon ? <Icon size={13} aria-hidden="true" /> : null}
<span>{label}</span> <span>{label}</span>
</div> </div>
@@ -1173,7 +1173,7 @@ function DetailSectionCard({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("surface-reading rounded-xl border border-slate-200/70 px-2.5 py-2.5 text-slate-500", className)}> <div className={cn("surface-reading rounded-xl border border-[var(--surface-divider)]/70 px-2.5 py-2.5 text-[var(--ink-muted)]", className)}>
{children} {children}
</div> </div>
); );
@@ -1200,7 +1200,7 @@ function TaskExecutionPanel({
}) { }) {
return ( return (
<section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}> <section className={cn(NEUTRAL_SURFACE_CLASS_NAME, "overflow-hidden")}>
<div className="border-b border-slate-200/70 px-3 py-3"> <div className="border-b border-[var(--surface-divider)]/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
{Icon ? ( {Icon ? (
<SectionTitle icon={Icon} title={title} /> <SectionTitle icon={Icon} title={title} />
@@ -1233,7 +1233,7 @@ function TaskExecutionPanel({
/> />
))} ))}
</ol> </ol>
{children ? <div className="grid gap-2 border-t border-slate-200/70 px-3 py-3">{children}</div> : null} {children ? <div className="grid gap-2 border-t border-[var(--surface-divider)]/70 px-3 py-3">{children}</div> : null}
</section> </section>
); );
} }
@@ -1249,7 +1249,7 @@ function TaskExecutionStepRow({
<li className="grid grid-cols-[28px_1fr] gap-2"> <li className="grid grid-cols-[28px_1fr] gap-2">
<span className="relative flex justify-center"> <span className="relative flex justify-center">
{!isLast ? ( {!isLast ? (
<span className="absolute bottom-[-0.65rem] top-7 w-px bg-slate-200" aria-hidden="true" /> <span className="absolute bottom-[-0.65rem] top-7 w-px bg-[var(--surface-divider)]" aria-hidden="true" />
) : null} ) : null}
<SemanticStatusIcon <SemanticStatusIcon
tone={getTaskExecutionStepTone(step.status)} tone={getTaskExecutionStepTone(step.status)}
@@ -1270,7 +1270,7 @@ function TaskExecutionStepRow({
</SemanticStatusIcon> </SemanticStatusIcon>
</span> </span>
<span <span
className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2.5 py-2" className="surface-reading min-w-0 rounded-xl border border-[var(--surface-divider)]/70 px-2.5 py-2"
> >
<span className="flex min-w-0 flex-wrap items-center justify-between gap-2"> <span className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className="text-xs font-semibold text-slate-900">{step.title}</span> <span className="text-xs font-semibold text-slate-900">{step.title}</span>
@@ -1279,10 +1279,10 @@ function TaskExecutionStepRow({
</StatusBadge> </StatusBadge>
</span> </span>
{step.meta?.length ? ( {step.meta?.length ? (
<span className="mt-1 grid gap-1 text-xs leading-4 text-slate-500 sm:grid-cols-[68px_1fr]"> <span className="mt-1 grid gap-1 text-xs leading-4 text-[var(--ink-muted)] sm:grid-cols-[68px_1fr]">
{step.meta.map((item) => ( {step.meta.map((item) => (
<span key={`${item.label}-${item.value}`} className="contents"> <span key={`${item.label}-${item.value}`} className="contents">
<span className="font-semibold text-slate-400">{item.label}</span> <span className="font-semibold text-[var(--ink-muted)]">{item.label}</span>
<span className={item.label === "时间" ? "tabular-nums text-slate-700" : "text-slate-700"}>{item.value}</span> <span className={item.label === "时间" ? "tabular-nums text-slate-700" : "text-slate-700"}>{item.value}</span>
</span> </span>
))} ))}
@@ -1304,18 +1304,18 @@ export function WorkOrderReportPanel({ condition }: { condition: ScheduledWorkOr
return ( return (
<div className="space-y-2.5"> <div className="space-y-2.5">
<DetailSectionCard className="overflow-hidden p-0"> <DetailSectionCard className="overflow-hidden p-0">
<div className="border-b border-slate-200/70 px-3 py-3"> <div className="border-b border-[var(--surface-divider)]/70 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<p className="text-[10px] font-semibold text-slate-400">工单报告</p> <p className="text-[10px] font-semibold text-[var(--ink-muted)]">工单报告</p>
<SectionTitle icon={ClipboardList} title="任务执行" /> <SectionTitle icon={ClipboardList} title="任务执行" />
</div> </div>
<StatusBadge tone={getStatusTone(condition.status)}>{progress.label}</StatusBadge> <StatusBadge tone={getStatusTone(condition.status)}>{progress.label}</StatusBadge>
</div> </div>
<p className="mt-2 whitespace-pre-wrap break-words text-xs leading-5 text-slate-700">{condition.summary}</p> <p className="mt-2 whitespace-pre-wrap break-words text-xs leading-5 text-slate-700">{condition.summary}</p>
<p className="mt-1 break-all text-[11px] text-slate-500">工单编号:{condition.code}</p> <p className="mt-1 break-all text-[11px] text-[var(--ink-muted)]">工单编号:{condition.code}</p>
<section aria-label="派单信息" className="mt-3"> <section aria-label="派单信息" className="mt-3">
<h4 className="mb-1.5 text-[11px] font-semibold text-slate-500">派单信息</h4> <h4 className="mb-1.5 text-[11px] font-semibold text-[var(--ink-muted)]">派单信息</h4>
<dl className="grid grid-cols-2 gap-1.5 sm:grid-cols-3"> <dl className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
<WorkOrderField label="来源" value={condition.source} /> <WorkOrderField label="来源" value={condition.source} />
<WorkOrderField label="位置" value={condition.location ?? "未填写"} /> <WorkOrderField label="位置" value={condition.location ?? "未填写"} />
@@ -1341,20 +1341,20 @@ export function WorkOrderReportPanel({ condition }: { condition: ScheduledWorkOr
<ol className="mt-2 grid gap-1.5"> <ol className="mt-2 grid gap-1.5">
{condition.feedbackRequirements.map((item, index) => ( {condition.feedbackRequirements.map((item, index) => (
<li key={`${index}-${item}`} className="flex gap-2 text-xs leading-5 text-slate-700"> <li key={`${index}-${item}`} className="flex gap-2 text-xs leading-5 text-slate-700">
<span className="grid size-5 shrink-0 place-items-center rounded-full border border-blue-100 bg-white text-[11px] font-semibold text-blue-700">{index + 1}</span> <span className="grid size-5 shrink-0 place-items-center rounded-full border border-[var(--surface-border)] bg-[var(--surface-reading)] text-[11px] font-semibold text-[var(--action-blue-hover)]">{index + 1}</span>
<span className="min-w-0 break-words">{item}</span> <span className="min-w-0 break-words">{item}</span>
</li> </li>
))} ))}
</ol> </ol>
) : <p className="mt-2 text-xs text-slate-500">未填写回执要求</p>} ) : <p className="mt-2 text-xs text-[var(--ink-muted)]">未填写回执要求</p>}
<div className="mt-3 border-t border-slate-200/70 pt-3"> <div className="mt-3 border-t border-[var(--surface-divider)]/70 pt-3">
<p className="text-[11px] font-semibold text-slate-500">现场反馈</p> <p className="text-[11px] font-semibold text-[var(--ink-muted)]">现场反馈</p>
{latestFeedback ? ( {latestFeedback ? (
<div className="mt-1 rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-700"> <div className="mt-1 rounded-lg bg-[var(--surface-reading)] px-3 py-2 text-xs leading-5 text-slate-700">
<p className="whitespace-pre-wrap break-words">{latestFeedback.detail}</p> <p className="whitespace-pre-wrap break-words">{latestFeedback.detail}</p>
<p className="mt-1 text-[11px] text-slate-500">{latestFeedback.actor} · {formatWorkOrderDateTime(latestFeedback.occurredAt)}</p> <p className="mt-1 text-[11px] text-[var(--ink-muted)]">{latestFeedback.actor} · {formatWorkOrderDateTime(latestFeedback.occurredAt)}</p>
</div> </div>
) : <p className="mt-1 text-xs text-slate-500">暂无现场反馈</p>} ) : <p className="mt-1 text-xs text-[var(--ink-muted)]">暂无现场反馈</p>}
</div> </div>
</section> </section>
</DetailSectionCard> </DetailSectionCard>
@@ -1368,7 +1368,7 @@ export function WorkOrderReportPanel({ condition }: { condition: ScheduledWorkOr
} }
function WorkOrderField({ label, value }: { label: string; value: string }) { function WorkOrderField({ label, value }: { label: string; value: string }) {
return <div className={cn(REPORT_META_TILE_CLASS_NAME, "min-w-0")}><dt className="text-[11px] font-semibold text-slate-500">{label}</dt><dd className="mt-0.5 break-words text-xs font-medium leading-5 text-slate-800">{value}</dd></div>; return <div className={cn(REPORT_META_TILE_CLASS_NAME, "min-w-0")}><dt className="text-[11px] font-semibold text-[var(--ink-muted)]">{label}</dt><dd className="mt-0.5 break-words text-xs font-medium leading-5 text-slate-800">{value}</dd></div>;
} }
function getWorkOrderExecutionSteps(condition: ScheduledWorkOrderItem, hasFeedback: boolean): ExecutionStep[] { function getWorkOrderExecutionSteps(condition: ScheduledWorkOrderItem, hasFeedback: boolean): ExecutionStep[] {
@@ -1478,7 +1478,7 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
{items.length > 0 ? ( {items.length > 0 ? (
items.map((item, index) => ( items.map((item, index) => (
<li key={item} className="grid grid-cols-[18px_1fr_auto] items-start gap-2 text-xs leading-5 text-slate-700"> <li key={item} className="grid grid-cols-[18px_1fr_auto] items-start gap-2 text-xs leading-5 text-slate-700">
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-blue-100 text-xs font-semibold text-blue-700"> <span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-[var(--surface-border)] text-xs font-semibold text-[var(--action-blue-hover)]">
{index + 1} {index + 1}
</span> </span>
<span className="line-clamp-1">{item}</span> <span className="line-clamp-1">{item}</span>
@@ -1488,7 +1488,7 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
</li> </li>
)) ))
) : ( ) : (
<li className="text-xs leading-5 text-slate-500">正在等待首批证据返回。</li> <li className="text-xs leading-5 text-[var(--ink-muted)]">正在等待首批证据返回。</li>
)} )}
</ol> </ol>
</DetailSectionCard> </DetailSectionCard>
@@ -1503,14 +1503,14 @@ function EvidenceList({ items }: { items: string[] }) {
{items.length > 0 ? ( {items.length > 0 ? (
items.map((item, index) => ( items.map((item, index) => (
<li key={item} className="grid grid-cols-[18px_1fr] gap-2 text-xs leading-5 text-slate-700"> <li key={item} className="grid grid-cols-[18px_1fr] gap-2 text-xs leading-5 text-slate-700">
<span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-blue-100 text-xs font-semibold text-blue-700"> <span className="surface-reading mt-0.5 grid h-[18px] w-[18px] place-items-center rounded-lg border border-[var(--surface-border)] text-xs font-semibold text-[var(--action-blue-hover)]">
{index + 1} {index + 1}
</span> </span>
<span>{item}</span> <span>{item}</span>
</li> </li>
)) ))
) : ( ) : (
<li className="text-xs leading-5 text-slate-500">暂无关键证据记录。</li> <li className="text-xs leading-5 text-[var(--ink-muted)]">暂无关键证据记录。</li>
)} )}
</ol> </ol>
</DetailSectionCard> </DetailSectionCard>
@@ -53,9 +53,9 @@ import {
} from "./scheduled-condition-feed-utils"; } from "./scheduled-condition-feed-utils";
const CONDITION_CONTENT_SURFACE_CLASS_NAME = const CONDITION_CONTENT_SURFACE_CLASS_NAME =
"surface-reading rounded-xl border border-slate-200/70"; "surface-reading rounded-xl border border-[var(--surface-divider)]/70";
const CONDITION_CONTROL_SURFACE_CLASS_NAME = const CONDITION_CONTROL_SURFACE_CLASS_NAME =
"surface-control rounded-xl border border-slate-200/70"; "surface-control rounded-xl border border-[var(--surface-divider)]/70";
const MINIMUM_REFRESH_FEEDBACK_MS = 3_000; const MINIMUM_REFRESH_FEEDBACK_MS = 3_000;
export type ScheduledConditionFeedPresentation = export type ScheduledConditionFeedPresentation =
@@ -416,7 +416,7 @@ export function ScheduledConditionFeed({
> >
<span <span
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "grid h-8 w-8 shrink-0 place-items-center bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
"rounded-lg" "rounded-lg"
)} )}
> >
@@ -424,7 +424,7 @@ export function ScheduledConditionFeed({
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2> <h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p> <p className="mt-0.5 truncate text-xs text-[var(--ink-muted)]">{headerSummary}</p>
</div> </div>
{onRefresh ? ( {onRefresh ? (
<button <button
@@ -433,7 +433,7 @@ export function ScheduledConditionFeed({
title={refreshing ? "正在刷新" : "刷新工况任务"} title={refreshing ? "正在刷新" : "刷新工况任务"}
disabled={refreshing} disabled={refreshing}
onClick={() => void handleRefresh()} onClick={() => void handleRefresh()}
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors active:scale-95 disabled:cursor-wait disabled:text-blue-600 [@media(hover:hover)]:hover:bg-blue-50 [@media(hover:hover)]:hover:text-blue-700" className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-[var(--ink-muted)] transition-colors active:scale-95 disabled:cursor-wait disabled:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
> >
<RefreshCw <RefreshCw
size={17} size={17}
@@ -447,7 +447,7 @@ export function ScheduledConditionFeed({
aria-expanded={expanded} aria-expanded={expanded}
onClick={handleToggleExpanded} onClick={handleToggleExpanded}
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center text-slate-500 transition hover:bg-blue-50 hover:text-blue-700", "grid h-8 w-8 shrink-0 place-items-center text-[var(--ink-muted)] transition hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]",
"rounded-lg" "rounded-lg"
)} )}
> >
@@ -594,17 +594,17 @@ function TaskCategorySwitch({
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
onClick={() => onChange(category.id)} onClick={() => onChange(category.id)}
className={cn( className={cn(
"min-h-12 min-w-0 rounded-lg px-1.5 py-1.5 text-left transition-[background-color,color,box-shadow] duration-150 motion-reduce:transition-none! focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-200", "min-h-12 min-w-0 rounded-lg px-1.5 py-1.5 text-left transition-[background-color,color,box-shadow] duration-150 motion-reduce:transition-none! focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]",
active active
? "bg-white text-slate-900 shadow-[0_1px_3px_rgba(15,23,42,0.1)]" ? "bg-[var(--surface-reading)] text-slate-900 shadow-[0_1px_3px_rgba(15,23,42,0.1)]"
: "text-slate-500 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-800" : "text-[var(--ink-muted)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/70 [@media(hover:hover)]:hover:text-slate-800"
)} )}
> >
<span className="flex min-w-0 items-center justify-between gap-1"> <span className="flex min-w-0 items-center justify-between gap-1">
<span className="truncate text-xs font-semibold">{category.label}</span> <span className="truncate text-xs font-semibold">{category.label}</span>
<span className="shrink-0 text-xs tabular-nums text-slate-400">{counts[category.id]}</span> <span className="shrink-0 text-xs tabular-nums text-[var(--ink-muted)]">{counts[category.id]}</span>
</span> </span>
<span className="mt-0.5 block truncate text-[10px] leading-3 text-slate-400"> <span className="mt-0.5 block truncate text-[10px] leading-3 text-[var(--ink-muted)]">
{category.sourceLabel} {category.sourceLabel}
</span> </span>
</button> </button>
@@ -772,7 +772,7 @@ function ConditionFilterBar({
<div className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mb-2 p-2")}> <div className={cn(CONDITION_CONTROL_SURFACE_CLASS_NAME, "mb-2 p-2")}>
<div className="mb-2 flex items-center justify-between gap-2 px-1"> <div className="mb-2 flex items-center justify-between gap-2 px-1">
<span className="text-xs font-semibold text-slate-700">筛选</span> <span className="text-xs font-semibold text-slate-700">筛选</span>
<span className="shrink-0 text-xs font-semibold text-slate-400"> <span className="shrink-0 text-xs font-semibold text-[var(--ink-muted)]">
{filteredConditionCount}/{totalConditionCount} {filteredConditionCount}/{totalConditionCount}
</span> </span>
</div> </div>
@@ -800,7 +800,7 @@ function ConditionFilterBar({
<button <button
type="button" type="button"
onClick={onResetFilters} onClick={onResetFilters}
className="surface-control relative mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-slate-200/70 px-2 text-xs font-semibold text-blue-700 after:absolute after:-inset-1 after:content-[''] hover:bg-blue-50" className="surface-control relative mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--surface-divider)]/70 px-2 text-xs font-semibold text-[var(--action-blue-hover)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--action-soft)]"
> >
<XCircle size={12} aria-hidden="true" /> <XCircle size={12} aria-hidden="true" />
重置筛选 重置筛选
@@ -831,13 +831,13 @@ function ConditionFilterDropdown<TValue extends string>({
return ( return (
<div className="min-w-0"> <div className="min-w-0">
<span className="mb-1 block text-xs font-semibold text-slate-400">{label}</span> <span className="mb-1 block text-xs font-semibold text-[var(--ink-muted)]">{label}</span>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className={cn( className={cn(
"surface-reading group flex h-9 w-full min-w-0 items-center gap-2 border border-slate-200/70 px-2 text-left text-xs outline-hidden transition-colors hover:bg-blue-50 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50 data-[state=open]:ring-2 data-[state=open]:ring-blue-100", "surface-reading group flex h-9 w-full min-w-0 items-center gap-2 border border-[var(--surface-divider)]/70 px-2 text-left text-xs outline-hidden transition-colors hover:bg-[var(--action-soft)] focus-visible:border-[var(--surface-border)] focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] data-[state=open]:border-[var(--surface-border)] data-[state=open]:bg-[var(--action-soft)] data-[state=open]:ring-2 data-[state=open]:ring-[var(--surface-border)]",
"rounded-xl" "rounded-xl"
)} )}
> >
@@ -848,21 +848,21 @@ function ConditionFilterDropdown<TValue extends string>({
> >
{selectedOption?.label ?? "全部"} {selectedOption?.label ?? "全部"}
</span> </span>
<span className="block text-xs leading-3 text-slate-400"> <span className="block text-xs leading-3 text-[var(--ink-muted)]">
{selectedOption?.count ?? 0} 条 {selectedOption?.count ?? 0} 条
</span> </span>
</span> </span>
<ChevronDown <ChevronDown
size={13} size={13}
aria-hidden="true" aria-hidden="true"
className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600" className="shrink-0 text-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)]"
/> />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
sideOffset={6} sideOffset={6}
className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-slate-200/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10" className="surface-reading scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-[var(--surface-divider)]/70 p-1.5 text-slate-700 shadow-md shadow-slate-900/10"
> >
<DropdownMenuRadioGroup <DropdownMenuRadioGroup
value={value} value={value}
@@ -873,12 +873,12 @@ function ConditionFilterDropdown<TValue extends string>({
key={option.value} key={option.value}
value={option.value} value={option.value}
className={cn( className={cn(
"my-0.5 min-h-9 gap-2 border border-transparent bg-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50 data-[state=checked]:text-blue-800", "my-0.5 min-h-9 gap-2 border border-transparent bg-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-[var(--action-soft)] focus:text-[var(--action-blue-pressed)] data-[state=checked]:border-[var(--surface-border)] data-[state=checked]:bg-[var(--action-soft)] data-[state=checked]:text-[var(--action-blue-pressed)]",
"rounded-xl" "rounded-xl"
)} )}
> >
<span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span> <span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span>
<span className="surface-control shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500"> <span className="surface-control shrink-0 rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-4 text-[var(--ink-muted)]">
{option.count} {option.count}
</span> </span>
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
@@ -892,12 +892,12 @@ function ConditionFilterDropdown<TValue extends string>({
function ConditionEmptyState({ title, description }: { title: string; description: string }) { function ConditionEmptyState({ title, description }: { title: string; description: string }) {
return ( return (
<div className="surface-reading flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200/80 px-4 py-5 text-center"> <div className="surface-reading flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-[var(--surface-divider)]/80 px-4 py-5 text-center">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400"> <span className="grid h-8 w-8 place-items-center rounded-lg bg-[var(--surface-control)] text-[var(--ink-muted)]">
<ChevronRight size={15} aria-hidden="true" /> <ChevronRight size={15} aria-hidden="true" />
</span> </span>
<p className="mt-2 text-xs font-semibold text-slate-700">{title}</p> <p className="mt-2 text-xs font-semibold text-slate-700">{title}</p>
<p className="mt-1 text-xs leading-4 text-slate-500">{description}</p> <p className="mt-1 text-xs leading-4 text-[var(--ink-muted)]">{description}</p>
</div> </div>
); );
} }
@@ -910,23 +910,23 @@ function ConditionTimelineSkeleton({ rows }: { rows: number }) {
key={index} key={index}
className="grid min-h-[60px] grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1" className="grid min-h-[60px] grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1"
> >
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" /> <span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-[var(--surface-divider)]/80" />
<span className="relative flex min-h-12 justify-center pt-1.5"> <span className="relative flex min-h-12 justify-center pt-1.5">
{index < rows - 1 ? ( {index < rows - 1 ? (
<span <span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-[var(--surface-divider)]"
aria-hidden="true" aria-hidden="true"
/> />
) : null} ) : null}
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" /> <span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-[var(--surface-divider)] ring-4 ring-white" />
</span> </span>
<span className="surface-reading min-w-0 rounded-xl border border-slate-200/70 px-2 py-1.5"> <span className="surface-reading min-w-0 rounded-xl border border-[var(--surface-divider)]/70 px-2 py-1.5">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 space-y-1.5"> <span className="min-w-0 flex-1 space-y-1.5">
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" /> <span className="block h-3.5 w-24 animate-pulse rounded-full bg-[var(--surface-divider)]/90" />
<span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-slate-100" /> <span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-[var(--surface-control)]" />
</span> </span>
<span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-slate-100" /> <span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-[var(--surface-control)]" />
</span> </span>
</span> </span>
</div> </div>
@@ -944,26 +944,26 @@ function ConditionDetailSkeleton() {
aria-label="工况详情加载中" aria-label="工况详情加载中"
> >
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-slate-100", "rounded-lg")} /> <span className={cn("h-9 w-9 shrink-0 animate-pulse bg-[var(--surface-control)]", "rounded-lg")} />
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<div className="flex gap-2"> <div className="flex gap-2">
<span className="h-4 w-12 animate-pulse rounded-full bg-slate-200" /> <span className="h-4 w-12 animate-pulse rounded-full bg-[var(--surface-divider)]" />
<span className="h-5 w-14 animate-pulse rounded-full bg-blue-100" /> <span className="h-5 w-14 animate-pulse rounded-full bg-[var(--action-selection-soft)]" />
</div> </div>
<span className="block h-4 w-44 animate-pulse rounded-full bg-slate-200" /> <span className="block h-4 w-44 animate-pulse rounded-full bg-[var(--surface-divider)]" />
<span className="block h-3 w-full animate-pulse rounded-full bg-slate-100" /> <span className="block h-3 w-full animate-pulse rounded-full bg-[var(--surface-control)]" />
<span className="block h-3 w-4/5 animate-pulse rounded-full bg-slate-100" /> <span className="block h-3 w-4/5 animate-pulse rounded-full bg-[var(--surface-control)]" />
</div> </div>
</div> </div>
<div className="mt-4 grid gap-2"> <div className="mt-4 grid gap-2">
{Array.from({ length: 5 }).map((_, index) => ( {Array.from({ length: 5 }).map((_, index) => (
<div <div
key={index} key={index}
className="surface-reading rounded-xl border border-slate-200/70 px-3 py-3" className="surface-reading rounded-xl border border-[var(--surface-divider)]/70 px-3 py-3"
> >
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" /> <span className="block h-3.5 w-28 animate-pulse rounded-full bg-[var(--surface-divider)]" />
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" /> <span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-[var(--surface-control)]" />
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" /> <span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-[var(--surface-control)]" />
</div> </div>
))} ))}
</div> </div>
@@ -1006,7 +1006,7 @@ function ConditionGroupHeader({
return ( return (
<div <div
className={cn( className={cn(
"flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", "flex items-center justify-between px-1 py-1 text-xs font-semibold text-[var(--ink-muted)]",
compact ? "mb-1" : "mb-2" compact ? "mb-1" : "mb-2"
)} )}
> >
@@ -1064,19 +1064,19 @@ function ConditionTimelineRow({
aria-pressed={selected} aria-pressed={selected}
onClick={onSelect} onClick={onSelect}
className={cn( className={cn(
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-blue-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-200", "group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl border px-1 py-1 text-left outline-hidden transition-colors focus-visible:bg-[var(--action-soft)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]",
selected selected
? "surface-reading border-blue-100 text-blue-900" ? "surface-reading border-[var(--surface-border)] text-[var(--action-blue-pressed)]"
: "border-transparent hover:bg-slate-50" : "border-transparent hover:bg-[var(--surface-reading)]"
)} )}
> >
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500"> <span className="pt-2 font-mono text-xs font-semibold leading-4 text-[var(--ink-muted)]">
{formatTime(condition.scheduledAt)} {formatTime(condition.scheduledAt)}
</span> </span>
<span className="relative flex min-h-12 justify-center pt-1.5"> <span className="relative flex min-h-12 justify-center pt-1.5">
{!isLast ? ( {!isLast ? (
<span <span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300" className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-[var(--surface-divider)] transition-colors group-hover:bg-[var(--action-selection-soft)] group-focus-visible:bg-[var(--action-blue)]"
aria-hidden="true" aria-hidden="true"
/> />
) : null} ) : null}
@@ -1087,7 +1087,7 @@ function ConditionTimelineRow({
data-selected={selected ? "true" : undefined} data-selected={selected ? "true" : undefined}
className={cn( className={cn(
"relative z-10 ring-4 ring-white", "relative z-10 ring-4 ring-white",
selected && "group-focus-visible:ring-blue-100" selected && "group-focus-visible:ring-[var(--action-focus-ring)]"
)} )}
> >
<Icon size={12} /> <Icon size={12} />
@@ -1096,7 +1096,7 @@ function ConditionTimelineRow({
<span <span
className={cn( className={cn(
"min-w-0 px-2 py-1.5 transition-colors", "min-w-0 px-2 py-1.5 transition-colors",
selected ? "text-blue-900" : "text-slate-700" selected ? "text-[var(--action-blue-pressed)]" : "text-slate-700"
)} )}
> >
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
@@ -1104,7 +1104,7 @@ function ConditionTimelineRow({
<span className="block truncate text-xs font-semibold leading-4"> <span className="block truncate text-xs font-semibold leading-4">
{condition.title} {condition.title}
</span> </span>
<span className="block truncate text-xs leading-4 text-slate-500"> <span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
{condition.summary} {condition.summary}
</span> </span>
</span> </span>
@@ -19,7 +19,7 @@ export function TaskConversationReview({ task, onAccessDecision, onActionDecisio
const pendingActions = task.actions.filter((action) => action.approval_status === "pending" const pendingActions = task.actions.filter((action) => action.approval_status === "pending"
|| action.approval_status === "approved" && ["not_submitted", "submitted", "unknown"].includes(action.execution_status)); || action.approval_status === "approved" && ["not_submitted", "submitted", "unknown"].includes(action.execution_status));
if (pendingAccess.length === 0 && pendingActions.length === 0) return null; if (pendingAccess.length === 0 && pendingActions.length === 0) return null;
return <section aria-label="当前任务审核" className="surface-reading space-y-2 rounded-xl border border-blue-200/80 p-3"> return <section aria-label="当前任务审核" className="surface-reading space-y-2 rounded-xl border border-[var(--surface-border)]/80 p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><ShieldCheck size={14} aria-hidden="true" />当前任务审核 · {task.title}</div> <div className="flex items-center gap-1.5 text-xs font-semibold text-slate-800"><ShieldCheck size={14} aria-hidden="true" />当前任务审核 · {task.title}</div>
{pendingAccess.map((request) => <AccessRequestCard key={request.access_request_id} request={request} {pendingAccess.map((request) => <AccessRequestCard key={request.access_request_id} request={request}
nodeTitle={task.latest_plan?.plan.nodes.find((node) => node.id === request.node_id)?.title} nodeTitle={task.latest_plan?.plan.nodes.find((node) => node.id === request.node_id)?.title}
@@ -450,7 +450,7 @@ function LegendPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
<MapLegendList items={legendItems} /> <MapLegendList items={legendItems} />
<div role="note" className="surface-control rounded-xl border px-3 py-2.5"> <div role="note" className="surface-control rounded-xl border px-3 py-2.5">
<p className="text-[11px] font-semibold text-slate-700">图例随图层显隐同步</p> <p className="text-[11px] font-semibold text-slate-700">图例随图层显隐同步</p>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500"> <p className="mt-1 text-[10px] leading-relaxed text-[var(--ink-muted)]">
管线颜色和线宽表示口径,设施颜色表示类型,风险色仅用于分析覆盖。 管线颜色和线宽表示口径,设施颜色表示类型,风险色仅用于分析覆盖。
</p> </p>
</div> </div>
@@ -61,8 +61,8 @@ export function WorkbenchAgentPanels({
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
className="group pointer-events-auto absolute inset-y-0 -right-1.5 z-10 w-3 cursor-col-resize touch-none" className="group pointer-events-auto absolute inset-y-0 -right-1.5 z-10 w-3 cursor-col-resize touch-none"
> >
<span className="pointer-events-none absolute inset-y-0 left-[5px] w-px bg-transparent transition-colors duration-150 group-hover:bg-blue-300/70 group-data-[resizing=true]:bg-blue-400/80 motion-reduce:transition-none" /> <span className="pointer-events-none absolute inset-y-0 left-[5px] w-px bg-transparent transition-colors duration-150 group-hover:bg-[var(--action-blue-hover)]/70 group-data-[resizing=true]:bg-[var(--action-blue)]/80 motion-reduce:transition-none" />
<span className="agent-resize-grip pointer-events-none absolute left-1/2 top-1/2 h-11 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-400 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-data-[resizing=true]:bg-blue-500 group-data-[resizing=true]:opacity-100 motion-reduce:transition-none" /> <span className="agent-resize-grip pointer-events-none absolute left-1/2 top-1/2 h-11 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--action-blue)] opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-data-[resizing=true]:bg-[var(--action-blue)] group-data-[resizing=true]:opacity-100 motion-reduce:transition-none" />
</div> </div>
) : null} ) : null}
</> </>
@@ -6,19 +6,19 @@ import { cn } from "@/shared/ui/cn";
export type WorkbenchTone = "neutral" | "info" | "success" | "warning" | "danger" | "teal"; export type WorkbenchTone = "neutral" | "info" | "success" | "warning" | "danger" | "teal";
const sectionTone: Record<WorkbenchTone, string> = { const sectionTone: Record<WorkbenchTone, string> = {
neutral: "bg-slate-100 text-slate-600", neutral: "bg-[var(--surface-control)] text-slate-600",
info: "bg-blue-50 text-blue-700", info: "bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
success: "bg-emerald-50 text-emerald-700", success: "bg-emerald-50 text-emerald-700",
warning: "bg-amber-50 text-amber-700", warning: "bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]",
danger: "bg-rose-50 text-rose-700", danger: "bg-rose-50 text-rose-700",
teal: "bg-cyan-50 text-cyan-800" teal: "bg-cyan-50 text-cyan-800"
}; };
const noticeTone: Record<Exclude<WorkbenchTone, "teal">, string> = { const noticeTone: Record<Exclude<WorkbenchTone, "teal">, string> = {
neutral: "border-slate-300 bg-slate-50 text-slate-700", neutral: "border-[var(--surface-border)] bg-[var(--surface-reading)] text-slate-700",
info: "border-blue-500 bg-blue-50/80 text-blue-950", info: "border-[var(--action-blue)] bg-[var(--action-soft)]/80 text-[var(--action-blue-pressed)]",
success: "border-emerald-500 bg-emerald-50/80 text-emerald-950", success: "border-emerald-500 bg-emerald-50/80 text-emerald-950",
warning: "border-amber-500 bg-amber-50/90 text-amber-950", warning: "border-[var(--status-warning-mark)] bg-[var(--status-warning-soft)]/90 text-[var(--status-warning-foreground)]",
danger: "border-rose-500 bg-rose-50/90 text-rose-950" danger: "border-rose-500 bg-rose-50/90 text-rose-950"
}; };
@@ -53,12 +53,12 @@ export function WorkbenchSection({
return ( return (
<section <section
className={cn( className={cn(
"overflow-hidden rounded-[14px] border border-slate-200/90 bg-[var(--surface-reading)] shadow-[0_1px_2px_rgba(15,23,42,0.06)]", "overflow-hidden rounded-[14px] border border-[var(--surface-divider)]/90 bg-[var(--surface-reading)] shadow-[0_1px_2px_rgba(15,23,42,0.06)]",
className className
)} )}
{...props} {...props}
> >
<div className="flex min-h-14 items-start gap-3 border-b border-slate-200/75 px-3.5 py-3"> <div className="flex min-h-14 items-start gap-3 border-b border-[var(--surface-divider)]/75 bg-[var(--surface-control)] px-3.5 py-3">
{icon ? ( {icon ? (
<span className={cn("mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg", sectionTone[tone])}> <span className={cn("mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg", sectionTone[tone])}>
{icon} {icon}
@@ -69,7 +69,7 @@ export function WorkbenchSection({
<h3 className="text-[13px] font-semibold leading-5 text-slate-900">{title}</h3> <h3 className="text-[13px] font-semibold leading-5 text-slate-900">{title}</h3>
{meta} {meta}
</div> </div>
{description ? <p className="mt-0.5 text-xs leading-5 text-slate-500">{description}</p> : null} {description ? <p className="mt-0.5 text-xs leading-5 text-[var(--ink-muted)]">{description}</p> : null}
</div> </div>
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null} {actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
</div> </div>
@@ -125,10 +125,10 @@ export function WorkbenchEmptyState({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("rounded-xl border border-dashed border-slate-300 bg-slate-50/70 px-4 py-5 text-center", className)}> <div className={cn("rounded-xl border border-dashed border-[var(--surface-border)] bg-[var(--surface-reading)]/70 px-4 py-5 text-center", className)}>
{icon ? <span className="mx-auto grid size-8 place-items-center rounded-full bg-white text-slate-500 shadow-xs">{icon}</span> : null} {icon ? <span className="mx-auto grid size-8 place-items-center rounded-full bg-[var(--surface-reading)] text-[var(--ink-muted)] shadow-xs">{icon}</span> : null}
<p className={cn("text-xs font-semibold text-slate-700", icon && "mt-2")}>{title}</p> <p className={cn("text-xs font-semibold text-slate-700", icon && "mt-2")}>{title}</p>
{description ? <p className="mx-auto mt-1 max-w-72 text-[11px] leading-5 text-slate-500">{description}</p> : null} {description ? <p className="mx-auto mt-1 max-w-72 text-[11px] leading-5 text-[var(--ink-muted)]">{description}</p> : null}
{action ? <div className="mt-3 flex justify-center">{action}</div> : null} {action ? <div className="mt-3 flex justify-center">{action}</div> : null}
</div> </div>
); );
@@ -52,7 +52,7 @@ const menuSurfaceClassName = cn(
"acrylic-panel border p-2 text-slate-900" "acrylic-panel border p-2 text-slate-900"
); );
const menuReadableRowClassName = const menuReadableRowClassName =
"surface-well focus:bg-blue-50 focus:text-slate-950"; "surface-well focus:bg-[var(--action-soft)] focus:text-slate-950";
export function AnalysisViewMenu({ export function AnalysisViewMenu({
open, open,
onOpenChange, onOpenChange,
@@ -92,7 +92,7 @@ export function AnalysisViewMenu({
<span className="hidden max-w-[160px] truncate text-sm font-semibold xl:inline 2xl:max-w-[200px]"> <span className="hidden max-w-[160px] truncate text-sm font-semibold xl:inline 2xl:max-w-[200px]">
分析视图 · {title} 分析视图 · {title}
</span> </span>
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" /> <ChevronDown size={14} className="hidden shrink-0 text-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)] 2xl:block" aria-hidden="true" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}> <DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
@@ -213,28 +213,28 @@ export function AlertMenu({
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<StatusDot tone={tone} size="md" /> <StatusDot tone={tone} size="md" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span> <span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span> <span className="shrink-0 font-mono text-xs font-semibold leading-4 text-[var(--ink-muted)] tabular-nums">{alert.time}</span>
</span> </span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span> <span className="mt-1 block line-clamp-2 text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</span>
</span> </span>
<span className={cn(toneClassName, "grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true"> <span className={cn(toneClassName, "grid h-7 w-7 shrink-0 place-items-center bg-[var(--surface-reading)] text-[var(--ink-muted)] transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} /> <ChevronRight size={14} />
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
); );
}) : ( }) : (
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}> <div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className="min-w-0"> <div className="min-w-0">
<SemanticStatusIcon tone="success" size="md" className="mx-auto"> <SemanticStatusIcon tone="success" size="md" className="mx-auto">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
</SemanticStatusIcon> </SemanticStatusIcon>
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p> <p className="mt-1 text-xs font-semibold text-slate-700">当前无待复核工况</p>
<p className="mt-0.5 text-xs leading-4 text-slate-500">新的工况提醒会进入这里。</p> <p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的工况提醒会进入这里。</p>
</div> </div>
</div> </div>
)} )}
</div> </div>
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-slate-200/70 pt-2"> <div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-[var(--surface-divider)]/70 pt-2">
<MenuPlainButton <MenuPlainButton
label={conditionFeedVisible ? "工况面板已打开" : "打开工况面板"} label={conditionFeedVisible ? "工况面板已打开" : "打开工况面板"}
description="查看时间线与处置详情" description="查看时间线与处置详情"
@@ -294,7 +294,7 @@ export function UserMenu({
<UserRound size={14} aria-hidden="true" /> <UserRound size={14} aria-hidden="true" />
</span> </span>
<span className={cn(compactHeaderTextClassName, "text-sm font-semibold")}>{user.name}</span> <span className={cn(compactHeaderTextClassName, "text-sm font-semibold")}>{user.name}</span>
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 2xl:block" aria-hidden="true" /> <ChevronDown size={14} className="hidden shrink-0 text-[var(--ink-muted)] transition group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:rotate-180 group-data-[state=open]:text-[var(--action-blue-hover)] 2xl:block" aria-hidden="true" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}> <DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
@@ -310,7 +310,7 @@ export function UserMenu({
onSelect={onLogout ? () => void onLogout() : undefined} onSelect={onLogout ? () => void onLogout() : undefined}
className={cn( className={cn(
"px-2 py-2", "px-2 py-2",
!onLogout && "text-slate-400", !onLogout && "text-[var(--ink-muted)]",
MAP_COMPACT_RADIUS_CLASS_NAME MAP_COMPACT_RADIUS_CLASS_NAME
)} )}
> >
@@ -350,17 +350,17 @@ function AlertQueueSummary({
</SemanticStatusIcon> </SemanticStatusIcon>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2> <h2 className="truncate text-sm font-semibold leading-5 text-slate-950">异常处置</h2>
<p className="mt-0.5 text-xs leading-5 text-slate-500"> <p className="mt-0.5 text-xs leading-5 text-[var(--ink-muted)]">
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"} {hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
</p> </p>
<p className="mt-0.5 text-xs leading-4 text-slate-400">来源:{sourceLabel}</p> <p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">来源:{sourceLabel}</p>
</div> </div>
</div> </div>
<StatusBadge tone={tone}> <StatusBadge tone={tone}>
{hasAlerts ? "需复核" : "正常"} {hasAlerts ? "需复核" : "正常"}
</StatusBadge> </StatusBadge>
</div> </div>
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500"> <div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-[var(--ink-muted)]">
<StatusDot tone={conditionFeedVisible ? "info" : "neutral"} /> <StatusDot tone={conditionFeedVisible ? "info" : "neutral"} />
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span> <span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
</div> </div>
@@ -388,12 +388,12 @@ function MenuPlainButton({
"flex h-11 min-w-0 flex-col justify-center border px-3 text-left transition-colors disabled:cursor-default disabled:opacity-70", "flex h-11 min-w-0 flex-col justify-center border px-3 text-left transition-colors disabled:cursor-default disabled:opacity-70",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
disabled disabled
? "border-slate-200 bg-slate-50 text-slate-500" ? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
: "border-slate-200 bg-white text-slate-800 hover:border-blue-100 hover:bg-blue-50/55 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-100" : "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-800 hover:border-[var(--surface-border)] hover:bg-[var(--action-soft)]/55 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
)} )}
> >
<span className="truncate text-xs font-semibold leading-4">{label}</span> <span className="truncate text-xs font-semibold leading-4">{label}</span>
<span className="truncate text-xs font-medium leading-4 text-slate-500">{description}</span> <span className="truncate text-xs font-medium leading-4 text-[var(--ink-muted)]">{description}</span>
</button> </button>
); );
} }
@@ -418,8 +418,8 @@ function MenuPrimaryButton({
"inline-flex h-11 shrink-0 items-center justify-center gap-2 border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed", "inline-flex h-11 shrink-0 items-center justify-center gap-2 border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
disabled disabled
? "border-slate-200 bg-slate-50 text-slate-400" ? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
: "border-blue-200 bg-blue-600 text-white shadow-lg shadow-blue-600/20 hover:bg-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-200" : "border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20 hover:bg-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
)} )}
> >
<Icon size={14} aria-hidden="true" /> <Icon size={14} aria-hidden="true" />
@@ -443,10 +443,10 @@ function MenuHeader({
<div className={cn("surface-control flex items-center justify-between gap-3 border px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}> <div className={cn("surface-control flex items-center justify-between gap-3 border px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-1.5">
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null} {Icon ? <Icon size={13} className="shrink-0 text-[var(--ink-muted)]" aria-hidden="true" /> : null}
<span className="truncate text-sm font-semibold text-slate-950">{title}</span> <span className="truncate text-sm font-semibold text-slate-950">{title}</span>
</div> </div>
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "status-tone-danger text-[var(--status-foreground)]")}>{meta}</p> <p className={cn("mt-0.5 truncate text-xs text-[var(--ink-muted)]", tone === "warning" && "status-tone-danger text-[var(--status-foreground)]")}>{meta}</p>
</div> </div>
{tone === "warning" ? <StatusDot tone="danger" size="md" /> : null} {tone === "warning" ? <StatusDot tone="danger" size="md" /> : null}
</div> </div>
@@ -454,11 +454,11 @@ function MenuHeader({
} }
function MenuSectionLabel({ children }: { children: ReactNode }) { function MenuSectionLabel({ children }: { children: ReactNode }) {
return <DropdownMenuLabel className="px-2 pb-1 pt-2.5 text-xs font-semibold uppercase text-slate-500">{children}</DropdownMenuLabel>; return <DropdownMenuLabel className="px-2 pb-1 pt-2.5 text-xs font-semibold uppercase text-[var(--ink-muted)]">{children}</DropdownMenuLabel>;
} }
function MenuSeparator() { function MenuSeparator() {
return <DropdownMenuSeparator className="my-2 bg-slate-200/70" />; return <DropdownMenuSeparator className="my-2 bg-[var(--surface-divider)]/70" />;
} }
function MenuAction({ function MenuAction({
@@ -487,16 +487,16 @@ function MenuAction({
className={cn( className={cn(
"grid h-7 w-7 shrink-0 place-items-center border", "grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700", tone === "primary" && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
tone === "warning" && "status-icon status-tone-warning", tone === "warning" && "status-icon status-tone-warning",
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600" tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-600"
)} )}
> >
<Icon size={15} aria-hidden="true" /> <Icon size={15} aria-hidden="true" />
</span> </span>
<span className="min-w-0"> <span className="min-w-0">
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span> <span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">{description}</span> <span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">{description}</span>
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
); );
@@ -11,6 +11,6 @@ export const compactHeaderControlButtonClassName =
"h-10 w-10 justify-center px-0"; "h-10 w-10 justify-center px-0";
export const headerControlIconClassName = export const headerControlIconClassName =
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700"; "header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-[var(--surface-control)]/80 text-[var(--ink-muted)] transition-colors group-hover:bg-[var(--action-soft)] group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:bg-[var(--action-soft)] group-data-[state=open]:text-[var(--action-blue-hover)]";
export const compactHeaderTextClassName = "hidden min-[1800px]:inline"; export const compactHeaderTextClassName = "hidden min-[1800px]:inline";
@@ -108,7 +108,7 @@ export function WorkbenchTopBar({
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0"> <div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0">
<div <div
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", "grid h-8 w-8 shrink-0 place-items-center bg-[var(--palette-brand)] text-white shadow-lg shadow-[color:var(--palette-brand)]/20",
MAP_ICON_CELL_RADIUS_CLASS_NAME MAP_ICON_CELL_RADIUS_CLASS_NAME
)} )}
> >
@@ -119,7 +119,7 @@ export function WorkbenchTopBar({
<span className="sm:hidden">供水</span> <span className="sm:hidden">供水</span>
<span className="hidden sm:inline">供水管网智能调度系统</span> <span className="hidden sm:inline">供水管网智能调度系统</span>
</h1> </h1>
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block">调度工作台</p> <p className="hidden truncate text-xs leading-4 text-[var(--ink-muted)] sm:block">调度工作台</p>
</div> </div>
</div> </div>
@@ -363,7 +363,7 @@ function HeaderReadout({
}) { }) {
return ( return (
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap"> <div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null} {Icon ? <Icon size={14} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" /> : null}
<span className="sr-only">{label}</span> <span className="sr-only">{label}</span>
<span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800"> <span className="max-w-40 shrink-0 truncate text-sm font-semibold text-slate-800">
{value} {value}
@@ -373,5 +373,5 @@ function HeaderReadout({
} }
function HeaderDivider() { function HeaderDivider() {
return <div className="h-5 w-px shrink-0 bg-slate-200/80" />; return <div className="h-5 w-px shrink-0 bg-[var(--surface-divider)]/80" />;
} }
@@ -289,7 +289,7 @@ function TimelineMarker({
aria-label={`${formatClock(event.cursorTime)} ${event.title}`} aria-label={`${formatClock(event.cursorTime)} ${event.title}`}
onClick={() => onSelectEvent(event)} onClick={() => onSelectEvent(event)}
className={cn( className={cn(
"absolute z-20 grid -translate-x-1/2 -translate-y-1/2 place-items-center outline-hidden transition-[transform,box-shadow] hover:scale-125 focus-visible:ring-2 focus-visible:ring-blue-600", "absolute z-20 grid -translate-x-1/2 -translate-y-1/2 place-items-center outline-hidden transition-[transform,box-shadow] hover:scale-125 focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]",
compact ? "h-2 w-2" : "h-5 w-5", compact ? "h-2 w-2" : "h-5 w-5",
event.lane === "plan" && event.lane === "plan" &&
"rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]", "rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]",
@@ -481,7 +481,7 @@ function TimelineLegend({
}) })
} }
className={cn( className={cn(
"grid h-7 w-7 place-items-center rounded-lg outline-hidden transition-[transform,filter,opacity] hover:scale-110 focus-visible:ring-2 focus-visible:ring-blue-500 active:scale-95", "grid h-7 w-7 place-items-center rounded-lg outline-hidden transition-[transform,filter,opacity] hover:scale-110 focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] active:scale-95",
visible ? "opacity-100" : "grayscale opacity-25" visible ? "opacity-100" : "grayscale opacity-25"
)} )}
> >
@@ -219,7 +219,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
}); });
const canSave = canWrite && !busy && title.trim() && summary.trim(); const canSave = canWrite && !busy && title.trim() && summary.trim();
const editorInputClassName = "mt-1 h-9 rounded-lg border-slate-200 bg-white focus-visible:outline-0! focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"; const editorInputClassName = "mt-1 h-9 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)]";
const orderEditor = editorOpen ? ( const orderEditor = editorOpen ? (
<section <section
@@ -4,6 +4,7 @@ import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure"; import { Disclosure } from "@/shared/ui/disclosure";
import { Input } from "@/shared/ui/input";
import { ManagedTestResources } from "./managed-test-resources"; import { ManagedTestResources } from "./managed-test-resources";
import { WorkbenchNotice } from "../components/workbench-panel-ui"; import { WorkbenchNotice } from "../components/workbench-panel-ui";
import { import {
@@ -100,12 +101,12 @@ export function PeripheralSamplePanel({ open, onClose, getAccessToken, onAgentTr
结果未知时只查询原请求,不会重复创建或更新工单。 结果未知时只查询原请求,不会重复创建或更新工单。
</p> </p>
<div className="flex gap-2 pb-3"> <div className="flex gap-2 pb-3">
<input <Input
aria-label="提交请求号" aria-label="提交请求号"
value={receiptRequestId} value={receiptRequestId}
onChange={(event) => setReceiptRequestId(event.target.value.trim())} onChange={(event) => setReceiptRequestId(event.target.value.trim())}
placeholder="request_id" placeholder="request_id"
className="h-10 min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 font-mono text-xs focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" className="h-10 min-w-0 flex-1 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)] font-mono text-xs md:text-xs"
/> />
<Button <Button
variant="outline" variant="outline"
@@ -194,7 +194,7 @@ function AnalysisBlockView({
> >
<button <button
type="button" type="button"
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-blue-50/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/60" className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-blue-50/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]"
aria-label={`查看${block.title}详情`} aria-label={`查看${block.title}详情`}
onClick={onSelect} onClick={onSelect}
> >
+1 -1
View File
@@ -524,7 +524,7 @@ export const CodeBlockLanguageSelectorTrigger = ({
}: CodeBlockLanguageSelectorTriggerProps) => ( }: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger <SelectTrigger
className={cn( className={cn(
"h-7 border-none bg-transparent px-2 text-xs shadow-none", "form-field-inset h-7 border-none bg-transparent px-2 text-xs shadow-none",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -1276,7 +1276,7 @@ export const PromptInputSelectTrigger = ({
}: PromptInputSelectTriggerProps) => ( }: PromptInputSelectTriggerProps) => (
<SelectTrigger <SelectTrigger
className={cn( className={cn(
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors", "form-field-inset border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground", "hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
className className
)} )}
+1 -1
View File
@@ -37,7 +37,7 @@ const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>, React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper=""> <div className="form-field flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input
ref={ref} ref={ref}
+11 -11
View File
@@ -202,10 +202,10 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
disabled={disabled} disabled={disabled}
aria-labelledby={`${id}-label`} aria-labelledby={`${id}-label`}
aria-describedby={`${id}-value`} aria-describedby={`${id}-value`}
className="flex h-10 w-full min-w-0 items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-left text-sm tabular-nums text-slate-800 shadow-xs transition-[border-color,box-shadow,scale] duration-150 [@media(hover:hover)]:hover:border-slate-300 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/30 active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 disabled:cursor-not-allowed disabled:opacity-50" className="form-field flex h-10 w-full min-w-0 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-3 text-left text-sm tabular-nums text-slate-800 shadow-xs transition-[border-color,box-shadow,scale] duration-150 [@media(hover:hover)]:hover:border-[var(--surface-border)] active:scale-[0.99] motion-reduce:transition-none motion-reduce:active:scale-100 disabled:cursor-not-allowed disabled:opacity-50"
> >
<CalendarDays className="size-4 shrink-0 text-slate-400" aria-hidden="true" /> <CalendarDays className="size-4 shrink-0 text-[var(--ink-muted)]" aria-hidden="true" />
<span id={`${id}-value`} className={cn("min-w-0 truncate", !value && "text-slate-400")}> <span id={`${id}-value`} className={cn("min-w-0 truncate", !value && "text-[var(--ink-muted)]")}>
{displayValue(value)} {displayValue(value)}
</span> </span>
</button> </button>
@@ -214,7 +214,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
<DialogPrimitive.Overlay className="fixed inset-0 z-[80] bg-slate-950/20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" /> <DialogPrimitive.Overlay className="fixed inset-0 z-[80] bg-slate-950/20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content <DialogPrimitive.Content
aria-describedby={undefined} aria-describedby={undefined}
className="fixed left-1/2 top-1/2 z-[85] max-h-[calc(100dvh-1.5rem)] w-[352px] max-w-[calc(100vw-1.5rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-slate-200 bg-white p-4 shadow-[0_20px_60px_rgba(15,23,42,0.22)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95" className="fixed left-1/2 top-1/2 z-[85] max-h-[calc(100dvh-1.5rem)] w-[352px] max-w-[calc(100vw-1.5rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-4 shadow-[0_20px_60px_rgba(15,23,42,0.22)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
onOpenAutoFocus={(event) => { onOpenAutoFocus={(event) => {
if (sourceInvalid) return; if (sourceInvalid) return;
event.preventDefault(); event.preventDefault();
@@ -243,7 +243,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
inputMode="numeric" inputMode="numeric"
value={yearText} value={yearText}
aria-invalid={!validYear} aria-invalid={!validYear}
className="mt-1 h-10 rounded-lg border-slate-200 bg-white text-sm tabular-nums" className="mt-1 h-10 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)] text-sm tabular-nums"
onChange={(event) => { onChange={(event) => {
const nextText = event.target.value.replace(/\D/g, "").slice(0, 4); const nextText = event.target.value.replace(/\D/g, "").slice(0, 4);
setYearText(nextText); setYearText(nextText);
@@ -286,7 +286,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
</Button> </Button>
</div> </div>
<div <div
className="mt-2 grid grid-cols-7 text-center text-[11px] font-medium text-slate-400" className="mt-2 grid grid-cols-7 text-center text-[11px] font-medium text-[var(--ink-muted)]"
aria-hidden="true" aria-hidden="true"
> >
{WEEKDAYS.map((weekday) => ( {WEEKDAYS.map((weekday) => (
@@ -317,11 +317,11 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
aria-selected={selected} aria-selected={selected}
aria-label={`${draft.year}年${draft.month}月${day}日`} aria-label={`${draft.year}年${draft.month}月${day}日`}
className={cn( className={cn(
"aspect-square w-full min-w-0 rounded-lg text-xs tabular-nums text-slate-700 transition-[background-color,color,scale] duration-150 [@media(hover:hover)]:hover:bg-blue-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/40 active:scale-90 motion-reduce:transition-none motion-reduce:active:scale-100", "aspect-square w-full min-w-0 rounded-lg text-xs tabular-nums text-slate-700 transition-[background-color,color,scale] duration-150 [@media(hover:hover)]:hover:bg-[var(--action-soft)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] active:scale-90 motion-reduce:transition-none motion-reduce:active:scale-100",
isToday && isToday &&
!selected && !selected &&
"font-semibold text-blue-600 ring-1 ring-inset ring-blue-200", "font-semibold text-[var(--action-blue-hover)] ring-1 ring-inset ring-[var(--surface-border)]",
selected && "bg-blue-600 font-semibold text-white [@media(hover:hover)]:hover:bg-blue-700" selected && "bg-[var(--action-blue)] font-semibold text-white [@media(hover:hover)]:hover:bg-[var(--action-blue-hover)]"
)} )}
onClick={() => updateDraft({ ...draft, day })} onClick={() => updateDraft({ ...draft, day })}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -347,7 +347,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
); );
})} })}
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-slate-100 pt-3"> <div className="mt-3 grid grid-cols-2 gap-2 border-t border-[var(--surface-divider)] pt-3">
<FieldSelect <FieldSelect
label="小时" label="小时"
value={pad(draft.hour)} value={pad(draft.hour)}
@@ -368,7 +368,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
/> />
</div> </div>
{sourceInvalid ? ( {sourceInvalid ? (
<p role="alert" className="mt-2 text-xs text-amber-700"> <p role="alert" className="mt-2 text-xs text-[var(--status-warning-foreground)]">
当前值格式无效,请重新选择日期和时间。 当前值格式无效,请重新选择日期和时间。
</p> </p>
) : null} ) : null}
+1 -1
View File
@@ -27,7 +27,7 @@ export function Disclosure({
<ChevronDown <ChevronDown
aria-hidden aria-hidden
data-disclosure-indicator="accordion" data-disclosure-indicator="accordion"
className="disclosure-accordion-indicator size-5 shrink-0 rounded-md bg-slate-100 p-1 text-slate-500 transition-transform duration-150 motion-reduce:transition-none" className="disclosure-accordion-indicator size-5 shrink-0 rounded-md bg-[var(--surface-control)] p-1 text-[var(--ink-muted)] transition-transform duration-150 motion-reduce:transition-none"
/> />
</summary> </summary>
{children} {children}
+1 -1
View File
@@ -46,7 +46,7 @@ export function FieldSelect({
> >
<SelectTrigger <SelectTrigger
id={triggerId} id={triggerId}
className="h-auto min-h-10 w-full min-w-0 gap-2 rounded-lg border-slate-200 bg-white px-3 text-sm shadow-xs transition-[border-color,box-shadow,background-color] [@media(hover:hover)]:hover:border-slate-300 focus:ring-2 focus:ring-[var(--action-focus-ring)] motion-reduce:transition-none [&>span]:line-clamp-none [&>span]:whitespace-normal [&>span]:break-words [&>span]:text-left [&>svg]:shrink-0" className="h-auto min-h-10 w-full min-w-0 gap-2 rounded-lg border-[var(--surface-divider)] bg-[var(--surface-reading)] px-3 text-sm shadow-xs transition-[border-color,box-shadow,background-color] [@media(hover:hover)]:hover:border-[var(--surface-border)] motion-reduce:transition-none [&>span]:line-clamp-none [&>span]:whitespace-normal [&>span]:break-words [&>span]:text-left [&>svg]:shrink-0"
> >
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
+3 -9
View File
@@ -12,7 +12,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( className={cn(
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-hidden transition-[color,box-shadow]", "form-field group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-hidden transition-[color,border-color,box-shadow]",
"h-9 has-[>textarea]:h-auto", "h-9 has-[>textarea]:h-auto",
// Variants based on alignment. // Variants based on alignment.
@@ -21,12 +21,6 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className className
)} )}
{...props} {...props}
@@ -134,7 +128,7 @@ function InputGroupInput({
<Input <Input
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent", "flex-1 rounded-none border-0 bg-transparent shadow-none dark:bg-transparent",
className className
)} )}
{...props} {...props}
@@ -150,7 +144,7 @@ function InputGroupTextarea({
<Textarea <Textarea
data-slot="input-group-control" data-slot="input-group-control"
className={cn( className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent", "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none dark:bg-transparent",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "form-field flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className
)} )}
ref={ref} ref={ref}
+21
View File
@@ -0,0 +1,21 @@
import { forwardRef, type ComponentProps } from "react";
import { cn } from "./cn";
export const Radio = forwardRef<HTMLInputElement, Omit<ComponentProps<"input">, "type">>(
function Radio({ className, ...props }, ref) {
return (
<span className={cn("relative inline-flex size-5 shrink-0 align-middle", props.disabled && "opacity-50")}>
<input
{...props}
ref={ref}
type="radio"
className={cn(
"peer m-0 size-5 cursor-pointer appearance-none rounded-full border border-[var(--action-border)] bg-[var(--surface-reading)] shadow-xs transition-colors checked:border-[var(--action-blue)] checked:bg-[var(--action-blue)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-2 disabled:cursor-not-allowed motion-reduce:transition-none",
className
)}
/>
<span aria-hidden className="pointer-events-none absolute inset-0 m-auto size-2 rounded-full bg-white opacity-0 peer-checked:opacity-100" />
</span>
);
}
);
+1 -1
View File
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", "form-field flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-colors data-[placeholder]:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return ( return (
<textarea <textarea
className={cn( className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "form-field flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className
)} )}
ref={ref} ref={ref}
+177 -163
View File
@@ -7,25 +7,25 @@
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
@theme inline { @theme inline {
--color-background: hsl(var(--background)); --color-background: var(--surface-dock);
--color-foreground: hsl(var(--foreground)); --color-foreground: var(--ink);
--color-card: hsl(var(--card)); --color-card: var(--surface-reading);
--color-card-foreground: hsl(var(--card-foreground)); --color-card-foreground: var(--ink);
--color-popover: hsl(var(--popover)); --color-popover: var(--surface-reading);
--color-popover-foreground: hsl(var(--popover-foreground)); --color-popover-foreground: var(--ink);
--color-primary: hsl(var(--primary)); --color-primary: var(--action-blue);
--color-primary-foreground: hsl(var(--primary-foreground)); --color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary)); --color-secondary: var(--surface-control);
--color-secondary-foreground: hsl(var(--secondary-foreground)); --color-secondary-foreground: var(--ink);
--color-muted: hsl(var(--muted)); --color-muted: var(--surface-control);
--color-muted-foreground: hsl(var(--muted-foreground)); --color-muted-foreground: var(--ink-muted);
--color-accent: hsl(var(--accent)); --color-accent: var(--action-selection-soft);
--color-accent-foreground: hsl(var(--accent-foreground)); --color-accent-foreground: var(--action-blue-hover);
--color-destructive: hsl(var(--destructive)); --color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground)); --color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border)); --color-border: var(--surface-border);
--color-input: hsl(var(--input)); --color-input: var(--surface-border);
--color-ring: hsl(var(--ring)); --color-ring: var(--action-blue-hover);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
@@ -66,6 +66,13 @@
border-color: var(--color-gray-200, currentColor); border-color: var(--color-gray-200, currentColor);
} }
/* Fallback only: component focus styles take precedence over this layer. */
input:focus-visible,
button:not([data-slot="button"]):focus-visible {
outline: 2px solid var(--action-focus-ring);
outline-offset: 2px;
}
:where(*) { :where(*) {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
@@ -107,20 +114,28 @@
:root { :root {
color-scheme: light; color-scheme: light;
background: oklch(94.5% 0.012 250); --palette-paper: #F5F7FA;
--canvas-map: oklch(94.5% 0.012 250); --palette-frost: #E6E9F2;
--canvas-map-grid: rgba(37, 99, 235, 0.06); --palette-brand: #4263C7;
--acrylic-navigation: rgba(234, 243, 250, 0.64); --palette-sand: color-mix(in oklab, var(--palette-champagne) 40%, var(--palette-paper));
--acrylic-panel: rgba(223, 235, 246, 0.56); --palette-champagne: #D6C294;
--acrylic-control: rgba(242, 248, 253, 0.74); --surface-navigation: color-mix(in oklab, var(--palette-brand) 8%, var(--palette-frost));
--agent-floating-reading-surface: rgba(235, 244, 251, 0.82); --surface-panel: var(--palette-frost);
--agent-floating-reading-item: rgba(249, 251, 254, 0.72); --surface-subtle: #EBEEF5;
--agent-floating-reading-item-hover: rgba(249, 251, 254, 0.9); background: var(--canvas-map);
--agent-floating-reading-active: rgba(219, 234, 254, 0.82); --canvas-map: var(--palette-paper);
--agent-user-message-surface: rgba(224, 229, 249, 0.92); --canvas-map-grid: color-mix(in srgb, var(--palette-brand) 8%, transparent);
--agent-user-message-border: rgba(79, 70, 229, 0.22); --acrylic-navigation: color-mix(in srgb, var(--surface-navigation) 64%, transparent);
--agent-white-acrylic-surface: rgba(255, 255, 255, 0.86); --acrylic-panel: color-mix(in srgb, var(--surface-panel) 56%, transparent);
--agent-operational-surface: rgba(255, 255, 255, 0.86); --acrylic-control: color-mix(in srgb, var(--surface-subtle) 74%, transparent);
--agent-floating-reading-surface: color-mix(in srgb, var(--surface-panel) 82%, transparent);
--agent-floating-reading-item: color-mix(in srgb, var(--palette-paper) 72%, transparent);
--agent-floating-reading-item-hover: color-mix(in srgb, var(--palette-paper) 90%, transparent);
--agent-floating-reading-active: color-mix(in srgb, var(--action-selection-soft) 82%, transparent);
--agent-user-message-surface: color-mix(in srgb, var(--action-selection-soft) 92%, transparent);
--agent-user-message-border: var(--action-selection-border);
--agent-white-acrylic-surface: color-mix(in srgb, var(--palette-paper) 86%, transparent);
--agent-operational-surface: color-mix(in srgb, var(--surface-subtle) 86%, transparent);
--acrylic-panel-blur: 24px; --acrylic-panel-blur: 24px;
--acrylic-control-blur: 18px; --acrylic-control-blur: 18px;
--acrylic-backdrop-saturation: 1.08; --acrylic-backdrop-saturation: 1.08;
@@ -136,7 +151,7 @@
--scrollbar-thumb-active: rgba(71, 85, 105, 0.36); --scrollbar-thumb-active: rgba(71, 85, 105, 0.36);
--acrylic-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='.035'/%3E%3C/svg%3E"); --acrylic-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='.035'/%3E%3C/svg%3E");
--acrylic-border: rgba(255, 255, 255, 0.52); --acrylic-border: rgba(255, 255, 255, 0.52);
--acrylic-outline: rgba(63, 83, 108, 0.18); --acrylic-outline: color-mix(in srgb, var(--ink) 20%, transparent);
--acrylic-highlight: rgba(255, 255, 255, 0.68); --acrylic-highlight: rgba(255, 255, 255, 0.68);
--shadow-navigation: 0 1px 0 rgba(48, 65, 85, 0.14); --shadow-navigation: 0 1px 0 rgba(48, 65, 85, 0.14);
--shadow-panel: 0 12px 32px rgba(15, 33, 55, 0.14); --shadow-panel: 0 12px 32px rgba(15, 33, 55, 0.14);
@@ -147,103 +162,76 @@
0 18px 42px rgba(15, 33, 55, 0.18), 0 4px 12px rgba(15, 33, 55, 0.1), 0 18px 42px rgba(15, 33, 55, 0.18), 0 4px 12px rgba(15, 33, 55, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.72); inset 0 1px 0 rgba(255, 255, 255, 0.72);
--shadow-agent-operational: 0 12px 32px rgba(15, 33, 55, 0.14), 0 3px 10px rgba(15, 33, 55, 0.08); --shadow-agent-operational: 0 12px 32px rgba(15, 33, 55, 0.14), 0 3px 10px rgba(15, 33, 55, 0.08);
--surface-dock: oklch(96.5% 0.012 248); --surface-dock: var(--surface-subtle);
--surface-control: rgba(239, 246, 252, 0.92); --surface-control: color-mix(in srgb, var(--surface-subtle) 92%, transparent);
--surface-well: rgba(213, 227, 239, 0.66); --surface-well: color-mix(in srgb, var(--surface-panel) 66%, transparent);
--surface-reading: rgba(250, 252, 254, 0.96); --surface-reading: color-mix(in srgb, var(--palette-paper) 96%, transparent);
--glass-transient: rgba(232, 242, 250, 0.66); --glass-transient: color-mix(in srgb, var(--surface-panel) 66%, transparent);
--surface-border: oklch(86.5% 0.012 250); --surface-border: #BEC2CD;
--surface-divider: oklch(90.5% 0.009 250); --surface-divider: #D5D9E1;
--surface-shadow: 0 12px 32px rgba(15, 23, 42, 0.12); --surface-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
--radius-control: 12px; --radius-control: 12px;
--radius-panel: 16px; --radius-panel: 16px;
--action-blue: #2563eb; --action-blue: var(--palette-brand);
--action-blue-hover: #1d4ed8; --action-blue-hover: #3653B0;
--action-blue-pressed: #1e40af; --action-blue-pressed: #2B469C;
--action-soft: oklch(96.8% 0.018 255.5); --action-soft: var(--surface-subtle);
--action-soft-pressed: oklch(94.8% 0.028 255.5); --action-soft-pressed: var(--surface-panel);
--action-selection-soft: #e8f1ff; --action-selection-soft: #DAE5FD;
--action-selection-soft-hover: #dbeafe; --action-selection-soft-hover: color-mix(in oklab, var(--palette-brand) 12%, var(--palette-frost));
--action-selection-soft-pressed: #cfe2ff; --action-selection-soft-pressed: color-mix(in oklab, var(--palette-brand) 20%, var(--palette-frost));
--action-selection-border: rgba(59, 130, 246, 0.2); --action-selection-border: var(--palette-brand);
--action-border: oklch(88.2% 0.015 250); --action-border: var(--surface-border);
--action-border-hover: rgba(37, 99, 235, 0.3); --action-border-hover: var(--palette-brand);
--action-border-pressed: rgba(37, 99, 235, 0.44); --action-border-pressed: var(--action-blue);
--action-danger: #dc2626; --action-danger: #dc2626;
--action-danger-hover: #b91c1c; --action-danger-hover: #b91c1c;
--action-danger-pressed: #991b1b; --action-danger-pressed: #991b1b;
--action-focus-ring: rgba(37, 99, 235, 0.34); --action-focus-ring: var(--action-blue-hover);
--action-focus-offset: rgba(250, 252, 254, 0.96); --field-focus-border: var(--action-blue-hover);
--action-shadow-primary: 0 5px 12px rgba(37, 99, 235, 0.2); --action-focus-offset: var(--surface-reading);
--action-shadow-primary: 0 5px 12px rgba(66, 99, 199, 0.2);
--action-shadow-secondary: 0 2px 5px rgba(15, 33, 55, 0.08); --action-shadow-secondary: 0 2px 5px rgba(15, 33, 55, 0.08);
--action-shadow-primary-pressed: 0 1px 3px rgba(30, 64, 175, 0.18); --action-shadow-primary-pressed: 0 1px 3px rgba(43, 70, 156, 0.18);
--action-shadow-secondary-pressed: 0 1px 2px rgba(15, 33, 55, 0.06); --action-shadow-secondary-pressed: 0 1px 2px rgba(15, 33, 55, 0.06);
--ink: #0f172a; --ink: #232935;
--ink-muted: #4E5566;
--status-neutral-soft: oklch(96.9% 0.005 247.9); --status-neutral-soft: oklch(96.9% 0.005 247.9);
--status-neutral-border: oklch(90.6% 0.013 244.3); --status-neutral-border: oklch(90.6% 0.013 244.3);
--status-neutral-foreground: oklch(46.5% 0.04 253.9); --status-neutral-foreground: oklch(46.5% 0.04 253.9);
--status-neutral-mark: oklch(66% 0.039 253.6); --status-neutral-mark: oklch(66% 0.039 253.6);
--status-info-soft: oklch(97.1% 0.011 256.7); --status-info-soft: var(--surface-subtle);
--status-info-border: oklch(90.7% 0.031 252.7); --status-info-border: var(--surface-border);
--status-info-foreground: oklch(48.9% 0.124 258); --status-info-foreground: var(--action-blue-hover);
--status-info-mark: oklch(59.7% 0.124 257.2); --status-info-mark: var(--action-blue);
--status-success-soft: oklch(97.2% 0.011 158.8); --status-success-soft: oklch(97.2% 0.011 158.8);
--status-success-border: oklch(90.4% 0.031 159); --status-success-border: oklch(90.4% 0.031 159);
--status-success-foreground: oklch(51.2% 0.086 163.6); --status-success-foreground: oklch(51.2% 0.086 163.6);
--status-success-mark: oklch(62% 0.107 162); --status-success-mark: oklch(62% 0.107 162);
--status-warning-soft: oklch(97.2% 0.016 82.8); --status-warning-soft: color-mix(in oklab, var(--palette-champagne) 25%, var(--palette-paper));
--status-warning-border: oklch(89.5% 0.049 77.2); --status-warning-border: color-mix(in oklab, var(--palette-champagne) 40%, var(--palette-paper));
--status-warning-foreground: oklch(51.8% 0.105 62.8); --status-warning-foreground: #604D1D;
--status-warning-mark: oklch(67.2% 0.126 65.2); --status-warning-mark: #977D3C;
--status-danger-soft: oklch(96.5% 0.01 17.3); --status-danger-soft: oklch(96.5% 0.01 17.3);
--status-danger-border: oklch(87.8% 0.031 17.8); --status-danger-border: oklch(87.8% 0.031 17.8);
--status-danger-foreground: oklch(50.5% 0.118 22.2); --status-danger-foreground: oklch(50.5% 0.118 22.2);
--status-danger-mark: oklch(62.6% 0.142 22); --status-danger-mark: oklch(62.6% 0.142 22);
--tone-agent: oklch(95% 0.035 290); --tone-agent: var(--surface-subtle);
--background: 210 40% 98%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 221 83% 53%;
--primary-foreground: 210 40% 98%; --primary-foreground: 210 40% 98%;
--secondary: 214 32% 91%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 210 40% 96%;
--accent-foreground: 222 47% 11%;
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 221 83% 53%;
--radius: 0.5rem; --radius: 0.5rem;
} }
[data-basemap-tone="satellite"] { [data-basemap-tone="satellite"] {
--acrylic-navigation: rgba(232, 241, 249, 0.8); --acrylic-navigation: color-mix(in srgb, var(--surface-navigation) 80%, transparent);
--acrylic-panel: rgba(220, 232, 243, 0.72); --acrylic-panel: color-mix(in srgb, var(--surface-panel) 72%, transparent);
--acrylic-control: rgba(241, 247, 252, 0.86); --acrylic-control: color-mix(in srgb, var(--surface-subtle) 86%, transparent);
--agent-floating-reading-surface: rgba(228, 239, 247, 0.88); --agent-floating-reading-surface: color-mix(in srgb, var(--surface-panel) 88%, transparent);
--agent-floating-reading-item: rgba(247, 251, 254, 0.78); --glass-transient: color-mix(in srgb, var(--surface-panel) 76%, transparent);
--agent-floating-reading-item-hover: rgba(249, 252, 255, 0.92);
--agent-floating-reading-active: rgba(219, 234, 254, 0.88);
--agent-user-message-surface: rgba(222, 227, 249, 0.94);
--agent-user-message-border: rgba(79, 70, 229, 0.28);
--agent-white-acrylic-surface: rgba(255, 255, 255, 0.9);
--agent-operational-surface: rgba(255, 255, 255, 0.9);
--acrylic-panel-blur: 24px;
--acrylic-control-blur: 18px;
--acrylic-backdrop-saturation: 1.02; --acrylic-backdrop-saturation: 1.02;
--acrylic-exclusion: rgba(255, 255, 255, 0.04); --agent-floating-acrylic-backdrop: blur(var(--acrylic-control-blur))
--acrylic-luminosity: rgba(255, 255, 255, 0.1); saturate(var(--acrylic-backdrop-saturation));
--surface-control: rgba(242, 248, 253, 0.96);
--surface-well: rgba(217, 230, 241, 0.78);
--surface-reading: rgba(252, 253, 255, 0.985);
--glass-transient: rgba(232, 242, 250, 0.76);
--acrylic-outline: rgba(43, 63, 86, 0.28);
--shadow-panel: 0 14px 36px rgba(7, 18, 32, 0.22); --shadow-panel: 0 14px 36px rgba(7, 18, 32, 0.22);
--shadow-agent-history-bottom: 0 14px 14px -14px rgba(7, 18, 32, 0.4); --shadow-agent-history-bottom: 0 14px 14px -14px rgba(7, 18, 32, 0.4);
--shadow-control: 0 7px 20px rgba(7, 18, 32, 0.16); --shadow-control: 0 7px 20px rgba(7, 18, 32, 0.16);
@@ -252,6 +240,15 @@
0 20px 46px rgba(7, 18, 32, 0.26), 0 5px 14px rgba(7, 18, 32, 0.16), 0 20px 46px rgba(7, 18, 32, 0.26), 0 5px 14px rgba(7, 18, 32, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.68); inset 0 1px 0 rgba(255, 255, 255, 0.68);
--shadow-agent-operational: 0 14px 36px rgba(7, 18, 32, 0.2), 0 4px 12px rgba(7, 18, 32, 0.12); --shadow-agent-operational: 0 14px 36px rgba(7, 18, 32, 0.2), 0 4px 12px rgba(7, 18, 32, 0.12);
--agent-floating-reading-item: color-mix(in srgb, var(--palette-paper) 78%, transparent);
--agent-floating-reading-item-hover: color-mix(in srgb, var(--palette-paper) 92%, transparent);
--agent-floating-reading-active: color-mix(in srgb, var(--action-selection-soft) 88%, transparent);
--agent-user-message-surface: color-mix(in srgb, var(--action-selection-soft) 94%, transparent);
--agent-white-acrylic-surface: color-mix(in srgb, var(--palette-paper) 90%, transparent);
--agent-operational-surface: color-mix(in srgb, var(--surface-subtle) 90%, transparent);
--surface-control: color-mix(in srgb, var(--surface-subtle) 96%, transparent);
--surface-well: color-mix(in srgb, var(--surface-panel) 78%, transparent);
--surface-reading: color-mix(in srgb, var(--palette-paper) 98.5%, transparent);
} }
* { * {
@@ -271,9 +268,30 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
input:focus-visible { /* A field or its group owns one focus boundary; inner controls stay borderless. */
outline: 2px solid #0f172a; .form-field {
outline-offset: 2px; outline: 0;
}
.form-field:is(:focus, :focus-within) {
border-color: var(--field-focus-border);
}
.form-field-inset:focus-visible {
outline: 1px solid var(--field-focus-border);
outline-offset: -1px;
}
.form-field[aria-invalid="true"],
.form-field:has([aria-invalid="true"]) {
--field-focus-border: var(--action-danger);
border-color: var(--field-focus-border);
}
@media (forced-colors: active) {
.form-field:is(:focus, :focus-within) {
--field-focus-border: Highlight;
}
} }
button { button {
@@ -286,11 +304,6 @@ button:not([data-slot="button"]) {
transition-timing-function: cubic-bezier(0.2, 0, 0, 1); transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
} }
button:not([data-slot="button"]):focus-visible {
outline: 2px solid var(--action-focus-ring);
outline-offset: 2px;
}
button:not([data-slot="button"]):not(:disabled):active { button:not([data-slot="button"]):not(:disabled):active {
filter: brightness(0.97) saturate(1.02); filter: brightness(0.97) saturate(1.02);
outline: 1px solid var(--action-border-pressed); outline: 1px solid var(--action-border-pressed);
@@ -321,33 +334,33 @@ button.action-control[aria-pressed="true"]:not(:disabled):active {
button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled) { button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled) {
border-color: var(--action-selection-border); border-color: var(--action-selection-border);
background-color: var(--action-selection-soft); background-color: var(--action-selection-soft);
color: var(--action-blue); color: var(--action-blue-hover);
} }
button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled) button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled)
.header-control-icon { .header-control-icon {
background-color: rgba(255, 255, 255, 0.68); background-color: rgba(255, 255, 255, 0.68);
color: #3b82f6; color: var(--action-blue-hover);
} }
@media (hover: hover) { @media (hover: hover) {
button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled):hover { button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled):hover {
border-color: var(--action-border-hover); border-color: var(--action-border-hover);
background-color: var(--action-selection-soft-hover); background-color: var(--action-selection-soft-hover);
color: var(--action-blue); color: var(--action-blue-pressed);
} }
button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled):hover button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled):hover
.header-control-icon { .header-control-icon {
background-color: rgba(255, 255, 255, 0.82); background-color: rgba(255, 255, 255, 0.82);
color: var(--action-blue); color: var(--action-blue-hover);
} }
} }
button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled):active { button.action-control[data-selection-tone="soft"][aria-pressed="true"]:not(:disabled):active {
border-color: var(--action-border-pressed); border-color: var(--action-border-pressed);
background-color: var(--action-selection-soft-pressed); background-color: var(--action-selection-soft-pressed);
color: var(--action-blue-hover); color: var(--action-blue-pressed);
box-shadow: var(--action-shadow-secondary-pressed); box-shadow: var(--action-shadow-secondary-pressed);
filter: none; filter: none;
} }
@@ -432,7 +445,7 @@ button:disabled {
background-color: var(--acrylic-control); background-color: var(--acrylic-control);
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.86), inset 0 1px 0 rgba(255, 255, 255, 0.86),
0 0 0 1px rgba(59, 130, 246, 0.14), 0 0 0 1px var(--acrylic-outline),
var(--shadow-control); var(--shadow-control);
backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation)); backdrop-filter: blur(var(--acrylic-control-blur)) saturate(var(--acrylic-backdrop-saturation));
-webkit-backdrop-filter: blur(var(--acrylic-control-blur)) -webkit-backdrop-filter: blur(var(--acrylic-control-blur))
@@ -475,7 +488,7 @@ button:disabled {
.material-tone-agent { .material-tone-agent {
background-color: var(--tone-agent); background-color: var(--tone-agent);
box-shadow: inset 0 1px 0 rgba(196, 181, 253, 0.42); box-shadow: inset 0 1px 0 var(--surface-border);
} }
.status-tone-neutral { .status-tone-neutral {
@@ -558,13 +571,20 @@ button:disabled {
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
:root, :root,
[data-basemap-tone] { [data-basemap-tone] {
--agent-floating-reading-surface: rgba(250, 252, 255, 0.98); --surface-reading: var(--palette-paper);
--agent-floating-reading-item: rgba(250, 252, 255, 0.98); --surface-control: var(--surface-subtle);
--agent-floating-reading-item-hover: rgba(255, 255, 255, 0.98); --surface-well: var(--surface-panel);
--agent-floating-reading-active: rgba(232, 241, 255, 0.98); --acrylic-navigation: var(--surface-navigation);
--agent-user-message-surface: rgba(238, 240, 252, 0.98); --acrylic-panel: var(--surface-panel);
--agent-white-acrylic-surface: rgba(255, 255, 255, 0.98); --acrylic-control: var(--surface-subtle);
--agent-operational-surface: rgba(255, 255, 255, 0.98); --glass-transient: var(--surface-panel);
--agent-floating-reading-surface: var(--surface-panel);
--agent-floating-reading-item: var(--surface-subtle);
--agent-floating-reading-item-hover: var(--surface-reading);
--agent-floating-reading-active: var(--action-selection-soft);
--agent-user-message-surface: var(--action-selection-soft);
--agent-white-acrylic-surface: var(--surface-reading);
--agent-operational-surface: var(--surface-subtle);
--agent-floating-acrylic-backdrop: none; --agent-floating-acrylic-backdrop: none;
} }
@@ -572,7 +592,6 @@ button:disabled {
.acrylic-panel, .acrylic-panel,
.acrylic-control, .acrylic-control,
.glass-transient { .glass-transient {
background-color: rgba(250, 252, 255, 0.98);
background-image: none; background-image: none;
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
@@ -582,13 +601,20 @@ button:disabled {
@media (prefers-reduced-transparency: reduce) { @media (prefers-reduced-transparency: reduce) {
:root, :root,
[data-basemap-tone] { [data-basemap-tone] {
--agent-floating-reading-surface: rgba(250, 252, 255, 0.98); --surface-reading: var(--palette-paper);
--agent-floating-reading-item: rgba(250, 252, 255, 0.98); --surface-control: var(--surface-subtle);
--agent-floating-reading-item-hover: rgba(255, 255, 255, 0.98); --surface-well: var(--surface-panel);
--agent-floating-reading-active: rgba(232, 241, 255, 0.98); --acrylic-navigation: var(--surface-navigation);
--agent-user-message-surface: rgba(238, 240, 252, 0.98); --acrylic-panel: var(--surface-panel);
--agent-white-acrylic-surface: rgba(255, 255, 255, 0.98); --acrylic-control: var(--surface-subtle);
--agent-operational-surface: rgba(255, 255, 255, 0.98); --glass-transient: var(--surface-panel);
--agent-floating-reading-surface: var(--surface-panel);
--agent-floating-reading-item: var(--surface-subtle);
--agent-floating-reading-item-hover: var(--surface-reading);
--agent-floating-reading-active: var(--action-selection-soft);
--agent-user-message-surface: var(--action-selection-soft);
--agent-white-acrylic-surface: var(--surface-reading);
--agent-operational-surface: var(--surface-subtle);
--agent-floating-acrylic-backdrop: none; --agent-floating-acrylic-backdrop: none;
} }
@@ -596,22 +622,10 @@ button:disabled {
.acrylic-panel, .acrylic-panel,
.acrylic-control, .acrylic-control,
.glass-transient { .glass-transient {
background-color: rgba(250, 252, 255, 0.98);
background-image: none; background-image: none;
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
} }
.surface-well,
.surface-control,
.surface-reading {
background-color: rgba(255, 255, 255, 0.98);
background-image: none;
}
.material-tone-agent {
background-color: #f5f3ff;
}
} }
@media (forced-colors: active) { @media (forced-colors: active) {
@@ -746,6 +760,11 @@ button:disabled {
border: 0; border: 0;
} }
.agent-panel-composer.form-field:focus-within {
outline: 1px solid var(--field-focus-border);
outline-offset: -1px;
}
.agent-panel-operational-float { .agent-panel-operational-float {
top: 4.75rem; top: 4.75rem;
background-color: var(--agent-operational-surface); background-color: var(--agent-operational-surface);
@@ -775,10 +794,8 @@ button.agent-history-session-trigger:not(:disabled):active {
outline: none; outline: none;
} }
.agent-history-rename-input, .agent-history-rename-input {
.agent-history-rename-input:focus-visible { border-color: var(--surface-border);
border-color: #cbd5e1;
outline: none;
box-shadow: none; box-shadow: none;
} }
@@ -787,12 +804,12 @@ button.agent-history-session-trigger:not(:disabled):active {
} }
.agent-panel-conversation { .agent-panel-conversation {
background: rgba(232, 240, 248, 0.12); background: transparent;
} }
.agent-panel-conversation-canvas { .agent-panel-conversation-canvas {
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: rgba(232, 240, 248, 0.18); background: var(--surface-subtle);
} }
.agent-panel-conversation button:not([data-slot="button"]):not(:disabled):active { .agent-panel-conversation button:not([data-slot="button"]):not(:disabled):active {
@@ -912,7 +929,7 @@ button.agent-history-session-trigger:not(:disabled):active {
.workbench-agent-dock .agent-panel-shell { .workbench-agent-dock .agent-panel-shell {
border: 0; border: 0;
border-radius: 0; border-radius: 0;
background: #f7f9fb; background: var(--surface-dock);
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.08); box-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
@@ -962,25 +979,23 @@ button.agent-history-session-trigger:not(:disabled):active {
} }
.agent-panel-primary-icon:disabled { .agent-panel-primary-icon:disabled {
background: #93c5fd; background: var(--palette-brand);
color: rgba(255, 255, 255, 0.86); color: rgba(255, 255, 255, 0.86);
} }
.agent-panel-shell[data-agent-presentation="desktop-dock"], .agent-panel-shell[data-agent-presentation="desktop-dock"],
.agent-panel-overlay[data-agent-presentation="desktop-dock"] { .agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-surface: oklch(97.2% 0.005 250); --agent-dock-surface: var(--surface-subtle);
--agent-dock-band: oklch(98.5% 0.003 250); --agent-dock-band: color-mix(in srgb, var(--palette-paper) 55%, transparent);
--agent-dock-control-shadow: --agent-dock-control-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 2px rgba(15, 23, 42, 0.04); inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 2px rgba(15, 23, 42, 0.04);
--agent-dock-overlay-surface: rgba(255, 255, 255, 0.94); --agent-dock-overlay-surface: color-mix(in srgb, var(--palette-paper) 94%, transparent);
--agent-dock-overlay-backdrop: blur(12px) saturate(1.03); --agent-dock-overlay-backdrop: blur(12px) saturate(1.03);
--agent-dock-overlay-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); --agent-dock-overlay-shadow: 0 6px 16px rgba(15, 23, 42, 0.08);
--surface-control: #ffffff; --agent-user-message-surface: var(--action-selection-soft);
--surface-reading: #ffffff;
--agent-user-message-surface: oklch(96.5% 0.018 255);
--agent-user-message-border: transparent; --agent-user-message-border: transparent;
--agent-floating-reading-item: transparent; --agent-floating-reading-item: transparent;
--agent-floating-reading-item-hover: oklch(97.2% 0.005 250); --agent-floating-reading-item-hover: var(--surface-subtle);
--agent-floating-reading-active: var(--action-selection-soft); --agent-floating-reading-active: var(--action-selection-soft);
--radius-panel: 12px; --radius-panel: 12px;
} }
@@ -1025,8 +1040,7 @@ button.agent-history-session-trigger:not(:disabled):active {
} }
.agent-panel-shell[data-agent-presentation="desktop-dock"] form.agent-panel-control:focus-within { .agent-panel-shell[data-agent-presentation="desktop-dock"] form.agent-panel-control:focus-within {
border-color: var(--action-blue); border-color: var(--field-focus-border);
box-shadow: var(--agent-dock-control-shadow), 0 0 0 2px rgba(37, 99, 235, 0.12);
} }
.agent-panel-shell[data-agent-presentation="desktop-dock"] :where(button) { .agent-panel-shell[data-agent-presentation="desktop-dock"] :where(button) {
@@ -1126,14 +1140,14 @@ button.agent-history-session-trigger:not(:disabled):active {
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.agent-panel-overlay[data-agent-presentation="desktop-dock"] { .agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-overlay-surface: #ffffff; --agent-dock-overlay-surface: var(--palette-paper);
--agent-dock-overlay-backdrop: none; --agent-dock-overlay-backdrop: none;
} }
} }
@media (prefers-reduced-transparency: reduce) { @media (prefers-reduced-transparency: reduce) {
.agent-panel-overlay[data-agent-presentation="desktop-dock"] { .agent-panel-overlay[data-agent-presentation="desktop-dock"] {
--agent-dock-overlay-surface: #ffffff; --agent-dock-overlay-surface: var(--palette-paper);
--agent-dock-overlay-backdrop: none; --agent-dock-overlay-backdrop: none;
} }
} }
@@ -1253,8 +1267,8 @@ button.agent-history-session-trigger:not(:disabled):active {
} }
.agent-panel-confirmation { .agent-panel-confirmation {
border: 1px solid rgba(253, 186, 116, 0.46); border: 1px solid var(--status-warning-border);
background: rgba(255, 247, 237, 0.82); background: var(--status-warning-soft);
} }
.agent-panel-enter { .agent-panel-enter {
+1 -59
View File
@@ -4,17 +4,7 @@ import { join } from "node:path";
const statusTones = ["neutral", "info", "success", "warning", "danger"] as const; const statusTones = ["neutral", "info", "success", "warning", "danger"] as const;
describe("semantic status language", () => { describe("semantic status language", () => {
it("keeps the production status foregrounds above 4.5:1", () => { it("defines the complete semantic token set", () => {
const styles = readFileSync(join(process.cwd(), "src/styles.css"), "utf8");
statusTones.forEach((tone) => {
const soft = readOklchToken(styles, `--status-${tone}-soft`);
const foreground = readOklchToken(styles, `--status-${tone}-foreground`);
expect(getOklchContrastRatio(foreground, soft), tone).toBeGreaterThanOrEqual(4.5);
});
});
it("defines the complete softened OKLCH token set", () => {
const styles = readFileSync(join(process.cwd(), "src/styles.css"), "utf8"); const styles = readFileSync(join(process.cwd(), "src/styles.css"), "utf8");
statusTones.forEach((tone) => { statusTones.forEach((tone) => {
@@ -50,51 +40,3 @@ describe("semantic status language", () => {
expect(agentPanel).toContain("AgentConnectionStatus"); expect(agentPanel).toContain("AgentConnectionStatus");
}); });
}); });
type OklchColor = {
lightness: number;
chroma: number;
hue: number;
};
function readOklchToken(styles: string, token: string): OklchColor {
const match = styles.match(
new RegExp(`${token}:\\s*oklch\\((\\d+(?:\\.\\d+)?)%\\s+(\\d+(?:\\.\\d+)?)\\s+(\\d+(?:\\.\\d+)?)\\)`)
);
if (!match) {
throw new Error(`Missing OKLCH token: ${token}`);
}
return {
lightness: Number(match[1]) / 100,
chroma: Number(match[2]),
hue: Number(match[3])
};
}
function getOklchContrastRatio(foreground: OklchColor, background: OklchColor) {
const foregroundLuminance = getOklchRelativeLuminance(foreground);
const backgroundLuminance = getOklchRelativeLuminance(background);
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
function getOklchRelativeLuminance({ lightness, chroma, hue }: OklchColor) {
const hueRadians = (hue * Math.PI) / 180;
const a = chroma * Math.cos(hueRadians);
const b = chroma * Math.sin(hueRadians);
const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b;
const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b;
const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b;
const l = lPrime ** 3;
const m = mPrime ** 3;
const s = sPrime ** 3;
const red = clampLinearRgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
const green = clampLinearRgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
const blue = clampLinearRgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
function clampLinearRgb(value: number) {
return Math.min(1, Math.max(0, value));
}
+13 -15
View File
@@ -1,6 +1,7 @@
import { expect, test, type Locator } from "@playwright/test"; import { expect, test, type Locator } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context"; import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { expectThemeColor, readColorAlpha, readThemeColor } from "./support/theme-colors";
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await mockWorkbenchContext(page); await mockWorkbenchContext(page);
@@ -53,18 +54,18 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
await waitForAnimations(agentPanel); await waitForAnimations(agentPanel);
await expectAcrylicSurface(topBar); await expectAcrylicSurface(topBar);
await expect(agentPanel).toHaveCSS("background-color", "oklch(0.972 0.005 250)"); await expectThemeColor(agentPanel, "background-color", "--agent-dock-surface");
await expect(agentPanel).toHaveCSS("backdrop-filter", "none"); await expect(agentPanel).toHaveCSS("backdrop-filter", "none");
await expect(agentPanel).toHaveCSS("border-radius", "0px"); await expect(agentPanel).toHaveCSS("border-radius", "0px");
await expect(agentPanel).toHaveCSS("border-top-width", "0px"); await expect(agentPanel).toHaveCSS("border-top-width", "0px");
await expect(agentPanel).toHaveCSS("border-bottom-width", "0px"); await expect(agentPanel).toHaveCSS("border-bottom-width", "0px");
await expect(agentPanel).toHaveCSS("border-left-width", "0px"); await expect(agentPanel).toHaveCSS("border-left-width", "0px");
await expect(agentPanel).toHaveCSS("border-right-width", "1px"); await expect(agentPanel).toHaveCSS("border-right-width", "1px");
await expect(agentPanel).toHaveCSS("border-right-color", "oklch(0.905 0.009 250)"); await expectThemeColor(agentPanel, "border-right-color", "--surface-divider");
await expect(agentPanel).toHaveCSS("box-shadow", "none"); await expect(agentPanel).toHaveCSS("box-shadow", "none");
await expect(agentControls).toHaveCSS("border-top-width", "1px"); await expect(agentControls).toHaveCSS("border-top-width", "1px");
await expect(agentControls).toHaveCSS("background-color", "oklch(0.985 0.003 250)"); await expectThemeColor(agentControls, "background-color", "--agent-dock-band");
await expect(agentPanel.locator("form.agent-panel-control")).toHaveCSS("background-color", "rgb(255, 255, 255)"); await expectThemeColor(agentPanel.locator("form.agent-panel-control"), "background-color", "--surface-reading");
const capabilities = agentPanel.getByRole("list", { name: "Agent 分析能力" }); const capabilities = agentPanel.getByRole("list", { name: "Agent 分析能力" });
await expect(capabilities.getByRole("listitem")).toHaveCount(4); await expect(capabilities.getByRole("listitem")).toHaveCount(4);
await expect(capabilities).toHaveCSS("box-shadow", "none"); await expect(capabilities).toHaveCSS("box-shadow", "none");
@@ -137,7 +138,7 @@ test("desktop Agent portal menus keep local material and respect reduced transpa
const menu = page.getByRole("menu"); const menu = page.getByRole("menu");
await expect(menu).toHaveAttribute("data-agent-presentation", "desktop-dock"); await expect(menu).toHaveAttribute("data-agent-presentation", "desktop-dock");
expect(await menu.evaluate((element) => element.closest("aside"))).toBeNull(); expect(await menu.evaluate((element) => element.closest("aside"))).toBeNull();
await expect(menu).toHaveCSS("background-color", "rgba(255, 255, 255, 0.94)"); await expectThemeColor(menu, "background-color", "--agent-dock-overlay-surface");
await expect(menu).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)"); await expect(menu).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
await expect(menu).toHaveCSS("background-image", "none"); await expect(menu).toHaveCSS("background-image", "none");
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
@@ -151,12 +152,12 @@ test("desktop Agent portal menus keep local material and respect reduced transpa
expect(await page.evaluate(() => matchMedia("(prefers-reduced-transparency: reduce)").matches)).toBe(true); expect(await page.evaluate(() => matchMedia("(prefers-reduced-transparency: reduce)").matches)).toBe(true);
await panel.getByRole("button", { name: "选择 Agent 模型" }).click(); await panel.getByRole("button", { name: "选择 Agent 模型" }).click();
const menu = page.getByRole("menu"); const menu = page.getByRole("menu");
await expect(menu).toHaveCSS("background-color", "rgb(255, 255, 255)"); await expectThemeColor(menu, "background-color", "--palette-paper");
await expect(menu).toHaveCSS("backdrop-filter", "none"); await expect(menu).toHaveCSS("backdrop-filter", "none");
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click(); await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
const history = panel.getByRole("region", { name: "Agent 历史记录" }); const history = panel.getByRole("region", { name: "Agent 历史记录" });
await expect(history).toHaveCSS("background-color", "rgb(255, 255, 255)"); await expectThemeColor(history, "background-color", "--palette-paper");
await expect(history).toHaveCSS("backdrop-filter", "none"); await expect(history).toHaveCSS("backdrop-filter", "none");
}); });
@@ -176,7 +177,7 @@ test("desktop Agent history remains usable inside the docked navigation pane", a
await waitForAnimations(history); await waitForAnimations(history);
await expect(historyButton).toHaveAttribute("aria-expanded", "true"); await expect(historyButton).toHaveAttribute("aria-expanded", "true");
await expectUnifiedHistoryStructure(history); await expectUnifiedHistoryStructure(history);
await expect(history).toHaveCSS("background-color", "rgba(255, 255, 255, 0.94)"); await expectThemeColor(history, "background-color", "--agent-dock-overlay-surface");
await expect(history).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)"); await expect(history).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
await history.getByRole("button", { name: "重命名对话" }).first().click(); await history.getByRole("button", { name: "重命名对话" }).first().click();
@@ -186,7 +187,7 @@ test("desktop Agent history remains usable inside the docked navigation pane", a
); );
await expect(renameInput).toBeFocused(); await expect(renameInput).toBeFocused();
await expect(renameInput).toHaveCSS("box-shadow", "none"); await expect(renameInput).toHaveCSS("box-shadow", "none");
await expect(renameInput).toHaveCSS("border-color", "rgb(203, 213, 225)"); await expectThemeColor(renameInput, "border-color", "--action-blue-hover");
await expect(editingItem).toHaveCSS("border-color", "rgba(0, 0, 0, 0)"); await expect(editingItem).toHaveCSS("border-color", "rgba(0, 0, 0, 0)");
await page.screenshot({ path: "test-results/agent-history-ui/desktop-rename.png" }); await page.screenshot({ path: "test-results/agent-history-ui/desktop-rename.png" });
await history.getByRole("button", { name: "取消重命名" }).click(); await history.getByRole("button", { name: "取消重命名" }).click();
@@ -414,6 +415,7 @@ test("long history titles keep rename and explicit delete usable across viewport
await history.getByRole("button", { name: "重命名对话", exact: true }).click(); await history.getByRole("button", { name: "重命名对话", exact: true }).click();
const input = history.getByRole("textbox"); const input = history.getByRole("textbox");
await expect(input).toBeFocused(); await expect(input).toBeFocused();
await expectThemeColor(input, "border-color", "--action-blue-hover");
await input.fill(`${originalTitle}(已复核)`); await input.fill(`${originalTitle}(已复核)`);
await expect(history.getByRole("button", { name: "保存对话主题" })).toBeVisible(); await expect(history.getByRole("button", { name: "保存对话主题" })).toBeVisible();
await waitForAnimations(history); await waitForAnimations(history);
@@ -632,9 +634,7 @@ async function expectTransparentExpandedHeader(header: Locator, agentSurface: Lo
boxShadow: style.boxShadow boxShadow: style.boxShadow
}; };
}), }),
agentSurface.evaluate((element) => readThemeColor(agentSurface, "--acrylic-outline")
getComputedStyle(element).getPropertyValue("--acrylic-outline").trim()
)
]); ]);
expect(getAlpha(presentation.backgroundColor)).toBe(0); expect(getAlpha(presentation.backgroundColor)).toBe(0);
@@ -665,9 +665,7 @@ async function getSurfacePresentation(surface: Locator) {
} }
function getAlpha(color: string) { function getAlpha(color: string) {
const match = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/); return readColorAlpha(color);
return match ? Number(match[1]) : 1;
} }
async function expectLightweightMaterialContext(context: Locator) { async function expectLightweightMaterialContext(context: Locator) {
+83
View File
@@ -0,0 +1,83 @@
import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { expectThemeColor } from "./support/theme-colors";
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
});
for (const width of [320, 375, 1440]) {
test(`layer panel basemap selection has one border and no persistent outer ring at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 });
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
const panel = page.getByRole("region", { name: "图层", exact: true });
const card = panel.getByRole("button", { name: "无底图", exact: true });
await expect(card).toHaveAttribute("aria-pressed", "true");
await expectThemeColor(card, "border-color", "--action-blue");
await expect(card).toHaveCSS("border-width", "1px");
await expect(card).toHaveCSS("box-shadow", "none");
await card.click();
await expect(card).toHaveCSS("outline-style", "none");
const restingBox = await card.boundingBox();
await page.keyboard.press("Tab");
await card.focus();
await expect(card).toHaveCSS("outline-width", "1px");
await expect(card).toHaveCSS("outline-offset", "-1px");
await expectThemeColor(card, "outline-color", "--action-focus-ring");
expect(await card.boundingBox()).toEqual(restingBox);
await page.keyboard.press("Enter");
await expect(card).toHaveAttribute("aria-pressed", "true");
await panel.screenshot({ path: `test-results/form-focus/layer-panel-${width}.png` });
});
}
test("standalone basemap controls share the same pointer and keyboard boundary", async ({ page }) => {
await renderControls(page);
for (const label of ["面板底图", "浮动底图"]) {
const control = page.getByRole("region", { name: label, exact: true });
if (label === "浮动底图") await control.getByRole("button", { name: "浮动底图:当前 浅色" }).hover();
const selected = control.getByRole("button", { name: "浅色", exact: true });
await expect(selected).toHaveCSS("border-width", "1px");
await expectThemeColor(selected, "border-color", "--action-blue");
const restingShadow = await selected.evaluate((element) => getComputedStyle(element).boxShadow);
expect(restingShadow).not.toMatch(/0px 0px 0px [12]px/);
await selected.click();
await expect(selected).toHaveCSS("outline-style", "none");
await page.keyboard.press("Tab");
await selected.focus();
await expect(selected).toHaveCSS("outline-width", "1px");
await expect(selected).toHaveCSS("outline-offset", "-1px");
await expect(selected).toHaveCSS("box-shadow", restingShadow);
const next = control.getByRole("button", { name: "影像", exact: true });
await next.focus();
await page.keyboard.press("Enter");
await expect(next).toHaveAttribute("aria-pressed", "true");
await expect(selected).toHaveAttribute("aria-pressed", "false");
await control.screenshot({ path: `test-results/form-focus/${label}.png` });
}
});
async function renderControls(page: Page) {
await page.evaluate(async () => {
const paths = ["/node_modules/.vite/deps/react.js", "/node_modules/.vite/deps/react-dom_client.js", "/src/features/map/core/components/base-layers-control.tsx"];
const [react, dom, controls] = await Promise.all(paths.map((path) => import(path)));
const h = react.default.createElement;
function Examples() {
const [panelId, setPanelId] = react.default.useState("light");
const [floatingId, setFloatingId] = react.default.useState("light");
const options = [{ id: "light", label: "浅色" }, { id: "satellite", label: "影像" }];
return h(react.default.Fragment, null,
h(controls.BaseLayersControl, { title: "面板底图", variant: "panel", options, activeLayerId: panelId, onSelectLayer: setPanelId }),
h(controls.BaseLayersControl, { title: "浮动底图", options, activeLayerId: floatingId, onSelectLayer: setFloatingId })
);
}
const container = document.createElement("div");
container.style.cssText = "position:fixed;z-index:40;top:80px;right:40px;padding:24px;background:var(--surface-reading);display:grid;gap:32px";
document.body.append(container);
dom.default.createRoot(container).render(h(Examples));
});
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api"; import { mockScadaApi } from "./support/mock-scada-api";
import { readThemeColor } from "./support/theme-colors";
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await prepareWorkbench(page); await prepareWorkbench(page);
@@ -32,7 +33,7 @@ test("desktop controls share focus, hover, press and open states", async ({ page
const restingBackground = await pressTarget.evaluate( const restingBackground = await pressTarget.evaluate(
(element) => getComputedStyle(element).backgroundColor (element) => getComputedStyle(element).backgroundColor
); );
expect(restingBackground).toBe("rgb(232, 241, 255)"); expect(restingBackground).toBe(await readThemeColor(pressTarget, "--action-selection-soft"));
await pressTarget.hover(); await pressTarget.hover();
const box = await pressTarget.boundingBox(); const box = await pressTarget.boundingBox();
expect(box).not.toBeNull(); expect(box).not.toBeNull();
@@ -48,7 +49,7 @@ test("desktop controls share focus, hover, press and open states", async ({ page
expect(pressedBox).toEqual(box); expect(pressedBox).toEqual(box);
await expect await expect
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).backgroundColor)) .poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).backgroundColor))
.toBe("rgb(207, 226, 255)"); .toBe(await readThemeColor(pressTarget, "--action-selection-soft-pressed"));
await page.mouse.move(720, 460); await page.mouse.move(720, 460);
await page.mouse.up(); await page.mouse.up();
await expect await expect
+140
View File
@@ -0,0 +1,140 @@
import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { expectThemeColor } from "./support/theme-colors";
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
});
test("Agent composer keeps one thin focus boundary without an outer blue ring", async ({ page }) => {
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const form = page.locator("form.agent-panel-control");
const textarea = page.getByPlaceholder(/输入调度问题/);
const restingShadow = await form.evaluate((element) => getComputedStyle(element).boxShadow);
expect(restingShadow).not.toBe("none");
const restingBox = await form.boundingBox();
await textarea.click();
await expectThemeColor(form, "border-color", "--action-blue-hover");
await expect(form).toHaveCSS("border-width", "1px");
await expect(form).toHaveCSS("box-shadow", restingShadow);
await expect(textarea).toHaveCSS("outline-style", "none");
expect(await form.boundingBox()).toEqual(restingBox);
await page.keyboard.press("Tab");
await textarea.focus();
await expectThemeColor(form, "border-color", "--action-blue-hover");
const focusedBox = await form.boundingBox();
await page.screenshot({
path: "test-results/form-focus/agent-focused.png",
clip: { x: focusedBox!.x - 4, y: focusedBox!.y - 4, width: focusedBox!.width + 8, height: focusedBox!.height + 8 }
});
});
test("shared fields and grouped inputs do not stack native outlines and focus rings", async ({ page }) => {
await renderFields(page);
for (const label of ["共享输入", "共享多行输入", "组合输入", "选择字段", "日期字段"]) {
const control = page.getByLabel(label, { exact: true });
await page.keyboard.press("Tab");
await control.focus();
await expect(control).toHaveCSS("outline-style", "none");
const owner = label === "组合输入" ? page.getByRole("group", { name: "组合输入区" }) : control;
await expectThemeColor(owner, "border-color", "--action-blue-hover");
await expect(owner).toHaveCSS("border-width", "1px");
const shadow = await owner.evaluate((element) => getComputedStyle(element).boxShadow);
expect(shadow).not.toMatch(/0px 0px 0px [12]px/);
}
const input = page.getByLabel("共享输入", { exact: true });
await input.evaluate((element) => element.setAttribute("aria-invalid", "true"));
await input.focus();
await expectThemeColor(input, "border-color", "--action-danger");
const groupedInput = page.getByLabel("组合输入", { exact: true });
await groupedInput.evaluate((element) => element.setAttribute("aria-invalid", "true"));
await groupedInput.focus();
await expectThemeColor(page.getByRole("group", { name: "组合输入区" }), "border-color", "--action-danger");
await page.locator("#focus-fields").screenshot({ path: "test-results/form-focus/shared-fields.png" });
});
test("borderless selects and choice controls keep one theme keyboard focus indicator", async ({ page }) => {
await renderFields(page);
const borderless = page.getByRole("combobox", { name: "无边框选择" });
await page.keyboard.press("Tab");
await borderless.focus();
await expect(borderless).toHaveCSS("border-width", "0px");
await expect(borderless).toHaveCSS("outline-style", "solid");
await expect(borderless).toHaveCSS("outline-width", "1px");
await expect(borderless).toHaveCSS("outline-offset", "-1px");
await expectThemeColor(borderless, "outline-color", "--action-blue-hover");
await page.keyboard.press("Enter");
await expect(page.getByRole("option", { name: "第二个选项" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(borderless).toBeFocused();
const firstRadio = page.getByRole("radio", { name: "单选一" });
await firstRadio.focus();
await expect(firstRadio).toHaveCSS("appearance", "none");
await page.keyboard.press("ArrowRight");
await expect(page.getByRole("radio", { name: "单选二" })).toBeChecked();
const checkbox = page.getByRole("checkbox", { name: "多选" });
await checkbox.focus();
await expect(checkbox).toHaveCSS("appearance", "none");
await page.keyboard.press("Space");
await expect(checkbox).toBeChecked();
await expect(checkbox).toHaveCSS("outline-style", "none");
const shadow = await checkbox.evaluate((element) => getComputedStyle(element).boxShadow);
expect(shadow).toContain("0px 0px 0px 4px");
await page.locator("#focus-fields").screenshot({ path: "test-results/form-focus/choice-fields.png" });
});
for (const width of [320, 375]) {
test(`mobile composer owns a single inset focus stroke at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const textarea = page.getByPlaceholder(/输入调度问题/);
await textarea.click();
const material = page.locator(".agent-panel-composer");
await expect(material).toHaveCSS("outline-width", "1px");
await expect(material).toHaveCSS("outline-offset", "-1px");
await expectThemeColor(material, "outline-color", "--action-blue-hover");
await expect(textarea).toHaveCSS("outline-style", "none");
await page.screenshot({ path: `test-results/form-focus/mobile-${width}.png` });
});
}
async function renderFields(page: Page) {
await page.evaluate(async () => {
// Load the production components through Vite, so the test exercises their real CSS.
const paths = ["/node_modules/.vite/deps/react.js", "/node_modules/.vite/deps/react-dom_client.js",
"/src/shared/ui/input.tsx", "/src/shared/ui/textarea.tsx", "/src/shared/ui/input-group.tsx",
"/src/shared/ui/field-select.tsx", "/src/shared/ui/date-time-field.tsx", "/src/shared/ui/select.tsx",
"/src/shared/ui/checkbox.tsx", "/src/shared/ui/radio.tsx", "/src/shared/ai-elements/prompt-input.tsx"];
const [react, dom, inputs, textareas, groups, selects, dates, primitives, checkboxes, radios, prompts] = await Promise.all(paths.map((path) => import(path)));
const h = react.default.createElement;
const container = document.createElement("div");
container.id = "focus-fields";
container.style.cssText = "position:fixed;z-index:40;top:80px;left:80px;width:360px;padding:24px;background:var(--surface-reading);display:grid;gap:16px";
document.body.append(container);
dom.default.createRoot(container).render(h(react.default.Fragment, null,
h(inputs.Input, { "aria-label": "共享输入", placeholder: "共享输入" }),
h(textareas.Textarea, { "aria-label": "共享多行输入", placeholder: "共享多行输入" }),
h(groups.InputGroup, { "aria-label": "组合输入区" }, h(groups.InputGroupInput, { "aria-label": "组合输入", placeholder: "组合输入" })),
h(selects.FieldSelect, { label: "选择字段", value: "first", onValueChange: () => {}, options: [{ value: "first", label: "第一个选项" }, { value: "second", label: "第二个选项" }] }),
h(dates.DateTimeField, { label: "日期字段", value: "2026-10-09T09:00", onValueChange: () => {} }),
h(primitives.Select, { defaultValue: "first" },
h(prompts.PromptInputSelectTrigger, { "aria-label": "无边框选择" }, h(primitives.SelectValue)),
h(primitives.SelectContent, null,
h(primitives.SelectItem, { value: "first" }, "第一个选项"),
h(primitives.SelectItem, { value: "second" }, "第二个选项")
)
),
h("div", { style: { display: "flex", gap: "16px" } },
h("label", null, h(radios.Radio, { name: "focus-radio", defaultChecked: true }), "单选一"),
h("label", null, h(radios.Radio, { name: "focus-radio" }), "单选二"),
h("label", null, h(checkboxes.Checkbox), "多选")
)
));
});
await expect(page.getByLabel("共享输入", { exact: true })).toBeVisible();
}
+26 -7
View File
@@ -1,3 +1,4 @@
import { expectThemeColor, readColorAlpha, readThemeColor } from "./support/theme-colors";
import { expect, test, type Locator, type Page } from "@playwright/test"; import { expect, test, type Locator, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
import { import {
@@ -114,10 +115,10 @@ test("assigned desktop panels avoid native form controls and horizontal overflow
const meterUnit = measurePanel.getByRole("button", { name: "m", exact: true }); const meterUnit = measurePanel.getByRole("button", { name: "m", exact: true });
const kilometerUnit = measurePanel.getByRole("button", { name: "km", exact: true }); const kilometerUnit = measurePanel.getByRole("button", { name: "km", exact: true });
await expect(kilometerUnit).toHaveAttribute("aria-pressed", "true"); await expect(kilometerUnit).toHaveAttribute("aria-pressed", "true");
await expect(kilometerUnit).toHaveClass(/bg-blue-600/); await expectThemeColor(kilometerUnit, "background-color", "--action-blue");
await meterUnit.click(); await meterUnit.click();
await expect(meterUnit).toHaveAttribute("aria-pressed", "true"); await expect(meterUnit).toHaveAttribute("aria-pressed", "true");
await expect(meterUnit).toHaveClass(/bg-blue-600/); await expectThemeColor(meterUnit, "background-color", "--action-blue");
await kilometerUnit.click(); await kilometerUnit.click();
const distanceMode = measurePanel.getByRole("button", { name: "距离", exact: true }); const distanceMode = measurePanel.getByRole("button", { name: "距离", exact: true });
const areaMode = measurePanel.getByRole("button", { name: "面积", exact: true }); const areaMode = measurePanel.getByRole("button", { name: "面积", exact: true });
@@ -136,14 +137,14 @@ test("assigned desktop panels avoid native form controls and horizontal overflow
await expect(measurePanel.getByRole("button", { name: "清除测量" })).toBeDisabled(); await expect(measurePanel.getByRole("button", { name: "清除测量" })).toBeDisabled();
await expect(measurePanel.getByText("等待开始", { exact: true })).toBeVisible(); await expect(measurePanel.getByText("等待开始", { exact: true })).toBeVisible();
const startMeasureButton = measurePanel.getByRole("button", { name: /开始测量/ }); const startMeasureButton = measurePanel.getByRole("button", { name: /开始测量/ });
await expect(startMeasureButton).toHaveClass(/bg-slate-50\/90/); await expect.poll(async () => readColorAlpha(await startMeasureButton.evaluate((element) => getComputedStyle(element).backgroundColor))).toBeCloseTo(0.864, 2);
await expect(startMeasureButton).not.toHaveClass(/bg-blue-600/); await expect(startMeasureButton).not.toHaveCSS("background-color", await readThemeColor(startMeasureButton, "--action-blue"));
await expect(startMeasureButton).toContainText("在地图中连续取点"); await expect(startMeasureButton).toContainText("在地图中连续取点");
await startMeasureButton.click(); await startMeasureButton.click();
const stopMeasureButton = measurePanel.getByRole("button", { name: /停止测量/ }); const stopMeasureButton = measurePanel.getByRole("button", { name: /停止测量/ });
await expect(stopMeasureButton).toBeVisible(); await expect(stopMeasureButton).toBeVisible();
await expect(stopMeasureButton).toHaveAttribute("data-state", "active"); await expect(stopMeasureButton).toHaveAttribute("data-state", "active");
await expect(stopMeasureButton).toHaveClass(/border-blue-300/); await expectThemeColor(stopMeasureButton, "border-color", "--palette-brand");
await expect(stopMeasureButton).toContainText("暂停并保留当前读数"); await expect(stopMeasureButton).toContainText("暂停并保留当前读数");
await expect(stopMeasureButton).toContainText("进行中"); await expect(stopMeasureButton).toContainText("进行中");
await expect(measurePanel.getByText("测量中", { exact: true })).toBeVisible(); await expect(measurePanel.getByText("测量中", { exact: true })).toBeVisible();
@@ -152,10 +153,11 @@ test("assigned desktop panels avoid native form controls and horizontal overflow
fullPage: true fullPage: true
}); });
await stopMeasureButton.click(); await stopMeasureButton.click();
await page.mouse.move(640, 56);
const resumedStartMeasureButton = measurePanel.getByRole("button", { name: /开始测量/ }); const resumedStartMeasureButton = measurePanel.getByRole("button", { name: /开始测量/ });
await expect(resumedStartMeasureButton).toBeEnabled(); await expect(resumedStartMeasureButton).toBeEnabled();
await expect(resumedStartMeasureButton).toHaveClass(/bg-slate-50\/90/); await expect.poll(async () => readColorAlpha(await resumedStartMeasureButton.evaluate((element) => getComputedStyle(element).backgroundColor))).toBeCloseTo(0.864, 2);
await expect(resumedStartMeasureButton).not.toHaveClass(/bg-blue-600/); await expect(resumedStartMeasureButton).not.toHaveCSS("background-color", await readThemeColor(resumedStartMeasureButton, "--action-blue"));
await page.screenshot({ await page.screenshot({
path: "test-results/non-map-panels/1280-measure-panel.png", path: "test-results/non-map-panels/1280-measure-panel.png",
fullPage: true fullPage: true
@@ -336,6 +338,23 @@ async function expectContained(locator: Locator, viewportWidth: number) {
} }
async function prepareWorkbench(page: Page) { async function prepareWorkbench(page: Page) {
// Layout fixtures keep the event stream open, just like the live backend.
await page.addInitScript(() => {
const fetch = window.fetch.bind(window);
window.fetch = (input, init) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes("/api/v1/workbench/events")) {
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(": fixture keepalive\n\n"));
init?.signal?.addEventListener("abort", () => controller.close(), { once: true });
}
});
return Promise.resolve(new Response(body, { headers: { "Content-Type": "text/event-stream" } }));
}
return fetch(input, init);
};
});
await mockAgentApi(page); await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) => await page.route("**/runtime-config.js", (route) =>
route.fulfill({ route.fulfill({
+140
View File
@@ -0,0 +1,140 @@
import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { expectThemeColor, readColorAlpha, readRenderedContrast, readThemeColor } from "./support/theme-colors";
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
});
test("gem-blue actions and semantic labels remain readable on the new surfaces", async ({ page }) => {
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await expectThemeColor(panel, "background-color", "--agent-dock-surface");
const tint = await panel.evaluate((element) => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
context.fillStyle = getComputedStyle(element).backgroundColor;
context.fillRect(0, 0, 1, 1);
return Array.from(context.getImageData(0, 0, 1, 1).data);
});
// Low-chroma material interpolation must stay in the cool silver/blue family.
expect(tint[2]).toBeGreaterThan(tint[1]);
expect(tint[2]).toBeGreaterThan(tint[0]);
await expectThemeColor(panel.locator("form.agent-panel-control"), "background-color", "--surface-reading");
const body = panel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。");
expect(await readRenderedContrast(body)).toBeGreaterThanOrEqual(4.5);
expect(await readRenderedContrast(panel.getByText("对话主题"))).toBeGreaterThanOrEqual(4.5);
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").fill("检查压力变化");
const send = panel.getByRole("button", { name: "发送 Agent 指令", exact: true });
await expect(send).toBeEnabled();
await expectThemeColor(send, "background-color", "--action-blue");
expect(await readRenderedContrast(send)).toBeGreaterThanOrEqual(4.5);
await send.hover();
await expectThemeColor(send, "background-color", "--action-blue-hover");
expect(await readRenderedContrast(send)).toBeGreaterThanOrEqual(4.5);
// Exercise every shared semantic tone after CSS variables and color-mix are resolved.
await panel.evaluate((element) => {
const samples = document.createElement("div");
samples.dataset.paletteSamples = "true";
for (const tone of ["neutral", "info", "success", "warning", "danger"]) {
const label = document.createElement("span");
label.className = `status-badge status-tone-${tone}`;
label.dataset.paletteTone = tone;
label.textContent = tone;
samples.append(label);
}
element.append(samples);
});
for (const tone of ["neutral", "info", "success", "warning", "danger"]) {
expect(await readRenderedContrast(panel.locator(`[data-palette-tone="${tone}"]`)), tone)
.toBeGreaterThanOrEqual(4.5);
}
});
test("selected controls preserve readable hover, pressed and keyboard focus states", async ({ page }) => {
const control = page.getByRole("button", { name: /任务浮条/ });
await expect(control).toHaveAttribute("aria-pressed", "true");
expect(await readRenderedContrast(control)).toBeGreaterThanOrEqual(4.5);
await control.hover();
await expectThemeColor(control, "background-color", "--action-selection-soft-hover");
expect(await readRenderedContrast(control)).toBeGreaterThanOrEqual(4.5);
const box = (await control.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await expectThemeColor(control, "background-color", "--action-selection-soft-pressed");
expect(await readRenderedContrast(control)).toBeGreaterThanOrEqual(4.5);
await page.mouse.move(720, 460);
await page.mouse.up();
await page.keyboard.press("Tab");
await control.focus();
expect(await control.evaluate((element) => element.matches(":focus-visible"))).toBe(true);
expect(await control.evaluate((element) => getComputedStyle(element).boxShadow)).not.toBe("none");
});
test("floating panel text stays readable over light and dark map backgrounds", async ({ page }) => {
// A solid underlay gives the transparent material a known actual backdrop.
await page.addStyleTag({ content: "#workspace-map .maplibregl-canvas { visibility: hidden; } .acrylic-panel { background-image: none; }" });
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
const layers = page.getByRole("region", { name: "图层", exact: true });
await expect(layers).toBeVisible();
const title = layers.getByText("图层", { exact: true }).first();
for (const tone of ["light", "satellite"]) {
await page.locator("main").evaluate((element, value) => {
element.setAttribute("data-basemap-tone", value);
(element as HTMLElement).style.backgroundColor = value === "light" ? "#f5f7fa" : "#172b3a";
}, tone);
await expectThemeColor(layers, "background-color", "--acrylic-panel");
expect(await readRenderedContrast(title), tone).toBeGreaterThanOrEqual(4.5);
const color = await layers.evaluate((element) => getComputedStyle(element).backgroundColor);
expect(readColorAlpha(color)).toBeCloseTo(tone === "light" ? 0.56 : 0.72, 2);
await expect(layers).toHaveCSS("backdrop-filter", "blur(24px) saturate(" + (tone === "light" ? "1.08" : "1.02") + ")");
}
});
for (const width of [320, 375, 390]) {
test(`new palette keeps Agent input and controls usable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
const composer = panel.locator("form.agent-panel-control");
await expect(composer).toBeVisible();
const material = panel.locator(".agent-panel-composer");
await expectThemeColor(material, "background-color", "--agent-white-acrylic-surface");
expect(readColorAlpha(await material.evaluate((element) => getComputedStyle(element).backgroundColor))).toBeCloseTo(0.86, 2);
await expect(material).toHaveCSS("backdrop-filter", "blur(18px) saturate(1.08)");
expect(await readRenderedContrast(panel.getByText("对话主题"))).toBeGreaterThanOrEqual(4.5);
for (const element of [panel, composer]) {
const box = (await element.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(width);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await page.screenshot({ path: `test-results/palette/agent-${width}.png` });
});
}
test("desktop palette captures dock, history, conditions and map tools", async ({ page }) => {
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
await page.screenshot({ path: "test-results/palette/desktop-1440.png" });
await page.setViewportSize({ width: 1920, height: 1080 });
await page.screenshot({ path: "test-results/palette/desktop-1920.png" });
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
const history = panel.getByRole("region", { name: "Agent 历史记录" });
await expect(history).toBeVisible();
expect(await readThemeColor(history, "--agent-dock-overlay-surface"))
.toBe(await history.evaluate((element) => getComputedStyle(element).backgroundColor));
await page.screenshot({ path: "test-results/palette/desktop-history.png" });
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const conditions = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditions).toBeVisible();
await conditions.getByRole("button", { name: "展开工况任务" }).click();
await page.screenshot({ path: "test-results/palette/desktop-report.png" });
});
+71
View File
@@ -0,0 +1,71 @@
import { expect, type Locator } from "@playwright/test";
export async function readThemeColor(locator: Locator, token: string) {
return locator.evaluate((element, name) => {
const probe = document.createElement("span");
probe.style.backgroundColor = getComputedStyle(element).getPropertyValue(name);
document.body.append(probe);
const color = getComputedStyle(probe).backgroundColor;
probe.remove();
return color;
}, token);
}
export function readColorAlpha(color: string) {
const rgba = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/);
const modern = color.match(/\/\s*([\d.]+)(%)?\s*\)$/);
return rgba ? Number(rgba[1]) : modern ? Number(modern[1]) / (modern[2] ? 100 : 1) : 1;
}
export async function expectThemeColor(locator: Locator, property: string, token: string) {
await expect(locator).toHaveCSS(property, await readThemeColor(locator, token));
}
// Canvas resolves RGB, OKLCH and color-mix() in the browser's sRGB output space.
// Compose ancestor backgrounds rather than assuming that the reading surface is white.
export async function readRenderedContrast(locator: Locator) {
return locator.evaluate((element) => {
const context = document.createElement("canvas").getContext("2d")!;
context.canvas.width = 1;
context.canvas.height = 1;
function rgba(color: string) {
context.clearRect(0, 0, 1, 1);
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
const [r, g, b, alpha] = context.getImageData(0, 0, 1, 1).data;
return { r, g, b, a: alpha / 255 };
}
function over(top: ReturnType<typeof rgba>, bottom: ReturnType<typeof rgba>) {
const a = top.a + bottom.a * (1 - top.a);
return {
r: (top.r * top.a + bottom.r * bottom.a * (1 - top.a)) / a,
g: (top.g * top.a + bottom.g * bottom.a * (1 - top.a)) / a,
b: (top.b * top.a + bottom.b * bottom.a * (1 - top.a)) / a,
a
};
}
function luminance(color: ReturnType<typeof rgba>) {
const linear = [color.r, color.g, color.b].map((channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
const ancestors: Element[] = [];
for (let current: Element | null = element; current; current = current.parentElement) {
ancestors.unshift(current);
}
let background = rgba("white");
for (const ancestor of ancestors) {
background = over(rgba(getComputedStyle(ancestor).backgroundColor), background);
}
const foreground = over(rgba(getComputedStyle(element).color), background);
const light = Math.max(luminance(foreground), luminance(background));
const dark = Math.min(luminance(foreground), luminance(background));
return (light + 0.05) / (dark + 0.05);
});
}
+13 -33
View File
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
import { mockScadaApi } from "./support/mock-scada-api"; import { mockScadaApi } from "./support/mock-scada-api";
import { readRenderedContrast, readThemeColor } from "./support/theme-colors";
const EMPTY_RASTER_TILE = Buffer.from( const EMPTY_RASTER_TILE = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
@@ -23,6 +24,7 @@ test.describe("Main Frame workbench", () => {
await expectSolidMainFrameMaterials(page); await expectSolidMainFrameMaterials(page);
await expectLoadedChineseFont(page); await expectLoadedChineseFont(page);
await expectBodyContrast(page); await expectBodyContrast(page);
await page.screenshot({ path: "test-results/palette/workbench-desktop-light.png", animations: "disabled" });
await expect(page).toHaveScreenshot("workbench-desktop-light.png", { await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
animations: "disabled", animations: "disabled",
maxDiffPixelRatio: 0.01 maxDiffPixelRatio: 0.01
@@ -38,6 +40,7 @@ test.describe("Main Frame workbench", () => {
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite"); await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
await expectDesktopMainFrameGeometry(page); await expectDesktopMainFrameGeometry(page);
await expectSolidMainFrameMaterials(page); await expectSolidMainFrameMaterials(page);
await page.screenshot({ path: "test-results/palette/workbench-desktop-satellite.png", animations: "disabled" });
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", { await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
animations: "disabled", animations: "disabled",
maxDiffPixelRatio: 0.01 maxDiffPixelRatio: 0.01
@@ -81,7 +84,7 @@ test.describe("Main Frame workbench", () => {
expect(await canvas.evaluate((element) => element.matches(":focus-visible"))).toBe(true); expect(await canvas.evaluate((element) => element.matches(":focus-visible"))).toBe(true);
await expect(canvas).toHaveCSS("outline-style", "solid"); await expect(canvas).toHaveCSS("outline-style", "solid");
await expect(canvas).toHaveCSS("outline-width", "1px"); await expect(canvas).toHaveCSS("outline-width", "1px");
await expect(canvas).toHaveCSS("outline-color", "rgba(37, 99, 235, 0.34)"); await expect(canvas).toHaveCSS("outline-color", await readThemeColor(canvas, "--action-focus-ring"));
await expect(canvas).toHaveCSS("outline-offset", "-1px"); await expect(canvas).toHaveCSS("outline-offset", "-1px");
await page.screenshot({ path: "test-results/agent-history-ui/map-pointer-focus.png" }); await page.screenshot({ path: "test-results/agent-history-ui/map-pointer-focus.png" });
}); });
@@ -112,6 +115,7 @@ test.describe("Main Frame workbench", () => {
.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0)) .poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0))
.toBe(1920); .toBe(1920);
expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width); expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width);
await page.screenshot({ path: "test-results/palette/workbench-desktop-wide-max-agent.png", animations: "disabled" });
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", { await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
animations: "disabled", animations: "disabled",
maxDiffPixelRatio: 0.01 maxDiffPixelRatio: 0.01
@@ -124,6 +128,7 @@ test.describe("Main Frame workbench", () => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await openWorkbench(page); await openWorkbench(page);
await page.screenshot({ path: "test-results/palette/workbench-mobile-launcher-light.png", animations: "disabled" });
await expect(page).toHaveScreenshot("workbench-mobile-launcher-light.png", { await expect(page).toHaveScreenshot("workbench-mobile-launcher-light.png", {
animations: "disabled", animations: "disabled",
maxDiffPixelRatio: 0.01 maxDiffPixelRatio: 0.01
@@ -138,6 +143,7 @@ test.describe("Main Frame workbench", () => {
const sheet = page.getByRole("region", { name: "Agent 工作台抽屉" }); const sheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
await expect(sheet).toHaveAttribute("data-snap", "half"); await expect(sheet).toHaveAttribute("data-snap", "half");
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439); await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439);
await page.screenshot({ path: "test-results/palette/workbench-mobile-agent-light.png", animations: "disabled" });
await expect(page).toHaveScreenshot("workbench-mobile-agent-light.png", { await expect(page).toHaveScreenshot("workbench-mobile-agent-light.png", {
animations: "disabled", animations: "disabled",
maxDiffPixelRatio: 0.01 maxDiffPixelRatio: 0.01
@@ -153,6 +159,7 @@ test.describe("Main Frame workbench", () => {
const sheet = page.getByRole("region", { name: "工况任务抽屉" }); const sheet = page.getByRole("region", { name: "工况任务抽屉" });
await expect(sheet).toHaveAttribute("data-snap", "half"); await expect(sheet).toHaveAttribute("data-snap", "half");
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite"); await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
await page.screenshot({ path: "test-results/palette/workbench-mobile-condition-satellite.png", animations: "disabled" });
await expect(page).toHaveScreenshot("workbench-mobile-condition-satellite.png", { await expect(page).toHaveScreenshot("workbench-mobile-condition-satellite.png", {
animations: "disabled", animations: "disabled",
maxDiffPixelRatio: 0.01 maxDiffPixelRatio: 0.01
@@ -308,7 +315,9 @@ async function expectSolidMainFrameMaterials(page: Page) {
}; };
}); });
expect(materials.agentBackdrop).toBe("none"); expect(materials.agentBackdrop).toBe("none");
expect(materials.agentBackground).toBe("oklch(0.972 0.005 250)"); expect(materials.agentBackground).toBe(
await readThemeColor(page.getByRole("complementary", { name: "Agent 命令面板" }), "--agent-dock-surface")
);
expect(materials.agentShadow).toBe("none"); expect(materials.agentShadow).toBe("none");
} }
@@ -321,35 +330,6 @@ async function expectLoadedChineseFont(page: Page) {
} }
async function expectBodyContrast(page: Page) { async function expectBodyContrast(page: Page) {
const contrast = await page const body = page.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。");
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。") expect(await readRenderedContrast(body)).toBeGreaterThanOrEqual(4.5);
.evaluate((element) => {
const foreground = parseColor(getComputedStyle(element).color);
const background = { r: 250, g: 252, b: 254 };
return contrastRatio(foreground, background);
function parseColor(color: string) {
const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [0, 0, 0];
return { r: channels[0] ?? 0, g: channels[1] ?? 0, b: channels[2] ?? 0 };
}
function contrastRatio(
first: { r: number; g: number; b: number },
second: { r: number; g: number; b: number }
) {
const lighter = Math.max(luminance(first), luminance(second));
const darker = Math.min(luminance(first), luminance(second));
return (lighter + 0.05) / (darker + 0.05);
}
function luminance(color: { r: number; g: number; b: number }) {
const [r, g, b] = [color.r, color.g, color.b].map((channel) => {
const normalized = channel / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
}
});
expect(contrast).toBeGreaterThanOrEqual(4.5);
} }