feat: redesign family companion customer portal

This commit is contained in:
Codex Local
2026-08-30 14:26:28 +08:00
parent 2337c6cfaa
commit c2c5521e83
7 changed files with 636 additions and 61 deletions
+46 -33
View File
@@ -1,46 +1,59 @@
# 回忆时间轴设计 QA
# 家庭陪伴门户 v0.74 设计 QA
- source visual truth: `E:\Codex\story\platform\.design-evidence\selected-conversation-timeline.png`
- implementation screenshot: `E:\Codex\story\platform\.design-evidence\implementation-conversation-timeline-final.png`
- normalized comparison: `selected-conversation-timeline-normalized.png` and `implementation-conversation-timeline-normalized.png`
- browser viewport: 1265 × 712 CSS px, device scale factor 1
- source pixels: 1487 × 1058; implementation full-page pixels: 1265 × 2150
- density normalization: source was resized and implementation was top-cropped to 1265 × 900 for an equal-size comparison
- state: authenticated owner, conversations route, one completed conversation selected, no custom model active
- 功能与页面规格:`docs/customer-portal-v074-page-spec.md`
- 设计稿源:`.design-evidence/v074/design/*.png`
- 实际页面截图:`.design-evidence/v074/implementation/*.png`
- 同屏对比证据:`.design-evidence/v074/comparison/*.png`
- 统一视口:1440 × 1024 CSS px,device scale factor 1
- 验收状态:本地所有者账号、合成数据一号家庭、2026-08-30、每页默认业务状态
## Full-view comparison evidence
## 页面验收矩阵
The equal-size comparison preserves the source's main information architecture: fixed family navigation, family tab, timeline title, left conversation stream, central AI memory note, and right model status. The implementation intentionally uses live test data and a compact filter header instead of the mock's date navigator and grouped demo dates. This does not remove any core task.
| 页面 | 核心功能 | 设计/实现对比 | 结果 |
| --- | --- | --- | --- |
| 首页 | 长者周记、心情线索、关怀提醒、最近动态 | `comparison/home.png` | 通过 |
| 聊天记录 | 时间轴、AI 摘要、关键词、故事候选、下次话题、模型来源 | `comparison/conversations.png` | 通过 |
| 故事与记忆 | 待审故事、审核入口、已确认记忆和搜索 | `comparison/stories.png` | 通过 |
| 长者资料 | 头像、长期资料、交流节奏、兴趣和授权入口 | `comparison/subjects.png` | 通过 |
| 日程与提醒 | 今日陪伴动态、待处理事项、近期状态汇总 | `comparison/messages.png` | 通过 |
| 家庭管理 | 家庭切换、成员、角色、邀请、家庭资料和动态 | `comparison/members.png` | 通过 |
| 陪伴设备 | 绑定、在线状态、固件、诊断和配置 | `comparison/devices.png` | 通过 |
| 内容分享 | 资源级分享、接收人、权限、期限和撤销 | `comparison/sharing.png` | 通过 |
| 数据与授权 | 数据盘点、更正、撤回、私有导出和主体删除 | `comparison/privacy.png` | 通过 |
| 服务用量 | 聊天时长、活跃日、热力图、设备与最近通话 | `comparison/billing.png` | 通过 |
| 大模型管理 | 模型配置、连接测试、激活、移除和密钥说明 | `comparison/models.png` | 通过 |
| 设置与权限 | 身份、家庭角色和 15 项完整权限矩阵 | `comparison/profile.png` | 通过 |
## Required fidelity surfaces
## 必检视觉项
- Fonts and typography: Chinese system sans-serif fallbacks, headline/body weights, line heights, hierarchy, wrapping, and muted metadata are consistent and readable. No clipping or truncation affects core content.
- Spacing and layout rhythm: the three-column frame, card padding, border radii, section gaps, selected state, and vertical rhythm follow the source. The narrower implementation viewport appropriately compresses the timeline and model columns.
- Colors and tokens: warm neutral page background, white/cream cards, restrained green status color, peach story surface, borders, and shadows map to the selected direction with accessible contrast.
- Image quality and assets: the selected live test record has no uploaded portrait, so the implementation uses the product's Ant Design icon system rather than inventing a decorative asset. No target logo or product image was replaced by CSS art or custom SVG.
- Copy and content: all labels stand alone in the product context. The implementation adds clear privacy wording for story candidates and identifies the exact model/result version when available.
- Interaction and accessibility: timeline items, full-chat action, story-review action, filters, family tabs, model management entry, and refresh controls are semantic buttons/controls. Disabled buttons use the required wait cursor. Form controls are labelled, and the selected/active/status states are not communicated by color alone.
- 页面标题与家庭标签紧邻,标题底部到家庭上下文栏的间距为 0;内容区统一 16px 节奏。
- 侧栏、页头、家庭 tab、卡片边框、圆角、按钮高度和表单控件使用同一套 v0.74 token。
- 1440px 视口下 12 个页面均无页面级横向溢出,无裁切的主要按钮或正文。
- 家庭管理重新出现在一级导航,使用可直接点击的家庭 tab,不再显示“客户组织”。
- 日程与提醒不再使用大段空白消息头;隐私空状态压缩到 150px;故事页长者筛选保持单行。
- 禁用按钮统一 `cursor: wait`;长者头像上传可显示本地预览。
Focused region comparison was not required: at 1265 × 900 both the source and implementation expose readable labels, typography, icon alignment, card spacing, selected states, and status treatments without zoom ambiguity.
## 功能检查
## Comparison history
- 12 个一级/二级导航均能进入对应页面。
- 新增长者头像上传与预览通过。
- 接受家庭邀请、邀请新成员、绑定设备、新建分享、资料更正、添加大模型、故事详情 7 个非破坏性弹窗打开/关闭通过。
- 聊天记录选中一条记录后,AI 总结区能进入真实加载/结果状态。
- 所有采样到的禁用按钮均显示等待光标。
- 浏览器控制台错误、非预期 HTTP 4xx/5xx、失败请求均为 0。
1. Earlier P2 — the implementation capture started below the family tab and page title because a legacy workspace rule hid the heading and the page retained scroll position. Fixed by adding section-specific workspace header overrides and recapturing a full-page state. Post-fix evidence shows the family tab, `回忆时间轴` title, status and refresh action above the three-column layout.
2. Earlier P1 — continuous SSE backlog events triggered all workspace REST effects every two seconds, eventually showing a rate-limit alert and preventing a selected conversation from rendering. Fixed by debouncing authoritative event refreshes to a 15-second minimum and loading recent summaries sequentially. Post-fix browser evidence shows 19 records loaded, a selected completed conversation, generated summary content, and no rate-limit alert.
3. Final comparison — no actionable P0, P1, or P2 mismatch remains. Live-data differences from the mock (no portrait, fewer generated-version comparisons, compact date filtering) are expected product-state differences and do not block the core journey.
## 对比迭代
## Findings
1. 修复家庭管理入口缺失,统一页面名称与客户视角的信息架构。
2. 移除工作区旧的大空白标题层,合并家庭 tab、实时状态、刷新和页面动作。
3. 故事页长者筛选从第二行收回同一上下文栏,并为故事卡补充“开始审核”。
4. 日程与提醒重构为今日动态、家人待办和近期汇总,不再堆叠无意义消息。
5. 设置页补齐家庭上下文,并集中列出 15 项角色权限。
6. 隐私页压缩空状态;用量页日期范围校准为 2025-09 至 2026-08。
7. 设计稿与实际页以相同 1440 × 1024 视口逐页同屏复核。
No actionable P0/P1/P2 findings.
## 最终结论
P3 follow-up polish: add a dedicated generation-version comparison drawer once multiple production model outputs are available for the same conversation.
## Implementation checklist
- [x] Three-column memory timeline implemented.
- [x] Summary, keywords, story candidate and next-topic states implemented.
- [x] Current model and generation provenance shown.
- [x] Core interactions and empty/disabled states verified in the in-app browser.
- [x] Rate-limit regression removed and browser console-visible page errors checked.
没有遗留的 P0、P1 或 P2 视觉/交互问题。设计稿中的演示故事、已配置模型和已有分享,与本地数据库的空状态属于业务数据差异;页面结构、视觉系统和核心操作保持一致。
final result: passed
+50
View File
@@ -0,0 +1,50 @@
# 安心陪伴客户门户 v0.74 页面功能与设计基准
日期:2026-08-30
设计方向:沿用已确认的“回忆时间轴”方案,以长者、聊天、心情与家庭协作为核心;暖白纸张底色、雾蓝主操作、陶土色提醒、低阴影和清晰留白。
## 全局信息架构
客户门户只保留家庭用户真正需要的 12 个一级任务。当前家庭用页首标签直接切换;家庭成员、角色和邀请恢复为独立的“家庭管理”页面。客户门户不再出现“客户组织”概念。
全局页面结构固定为:
1. 68px 顶部栏:品牌、全局搜索、提醒、账号。
2. 228px 左侧导航:围绕长者陪伴任务排列。
3. 统一页面首部:标题、用途说明、家庭权限状态。
4. 统一家庭上下文栏:家庭 Tab、添加家庭、实时状态、刷新和页面主操作。
5. 页面内容:不再使用包住整页的大卡片;只在独立对象或需要分组时使用边框容器。
## 逐页功能总结
| 页面 | 用户要解决的问题 | 主要功能 | 首屏重点 | 关键状态 |
|---|---|---|---|---|
| 首页 | 最近有没有陪伴、长者心情如何、哪里需要家人关注 | 长者切换、7 天聊天时长、最近聊天摘要、心情线索、关注提醒、最近动态 | 每位长者最近一次聊天和心情变化 | 无聊天、设备离线、待关注 |
| 聊天记录 | 找到某次聊天并理解聊天内容 | 按长者/日期/状态筛选、AI 摘要、关键词、故事候选、下次话题、录音授权 | 时间轴和当前聊天 AI 总结 | 总结中、总结失败、无授权 |
| 故事与记忆 | 审核模型整理的故事,形成可信长期记忆 | 故事候选审核、修订、确认/拒绝、记忆检索和停用 | 待审核故事与来源聊天 | 空状态、审核中、已确认 |
| 长者资料 | 让豆包知道如何更适合地与这位长者聊天 | 头像、背景、兴趣、优先/回避话题、交流节奏、长期陪伴方向、授权 | 长者卡片和资料完整度 | 头像上传、只读、资料不完整 |
| 日程与提醒 | 知道什么时候聊、哪些事情需要家人处理 | 陪伴计划、提醒时间、设备/故事/隐私待办、事件状态 | 今日安排和待办 | 已完成、即将开始、已错过 |
| 家庭管理 | 管理家庭空间和可以协作的家人 | 家庭 Tab、成员、邀请、角色、所有权、家庭名称 | 家庭成员与角色 | 待接受邀请、只读、危险操作确认 |
| 陪伴设备 | 确认设备能否正常陪伴 | 绑定、在线状态、固件、电量/音量、诊断、解除绑定 | 设备健康和所属长者 | 离线、待同步、诊断中 |
| 内容分享 | 把指定故事/文字/录音安全分享给指定家人 | 资源选择、对象、权限、有效期、撤销 | 当前有效分享 | 无分享、即将过期、已撤销 |
| 数据与授权 | 查看和控制长者数据如何被使用 | 五类分项授权、数据访问/更正/撤回/删除、私有导出 | 授权清单,不以按钮堆叠呈现 | 生效、处理中、已撤回 |
| 服务用量 | 理解聊天使用情况,而不是看无意义设备统计 | 时长指标、活跃日、长者/设备筛选、热力图、最近通话 | 最近一年聊天活动 | 无数据、费用未核价 |
| 大模型管理 | 选择哪个模型用于聊天总结 | 新增配置、连通性测试、激活、停用、参数和生成历史 | 当前激活模型及健康状态 | 未配置、测试失败、已激活 |
| 设置与权限 | 理解当前账号能做什么 | 账号资料、家庭角色、15 项权限矩阵、敏感录音独立授权说明 | 当前身份和权限全表 | 无家庭、权限不足 |
## 统一视觉规格
- 桌面基准:1440 × 1024;内容最大宽度 1360px。
- 页面左右 padding:桌面 28px;平板 20px;手机 14px。
- 页面首部上下 padding:24px / 18px;与家庭上下文栏间距 16px。
- 家庭上下文栏高度不固定,最小 54px;禁止通过空白占位制造层级。
- 页面 section 间距 16px;对象卡片 padding 20–24px;列表行 14–16px。
- 字号:页面标题 28px;区块标题 20px;正文 14px;辅助信息 12px。
- 圆角:页面对象 14px、按钮/输入框 9px、状态胶囊 999px。
- 主色 `#315D9B`;强调 `#C45F3B`;成功 `#5F8F69`;底色 `#FBFAF7`;边线 `#E6E0D8`。
- 禁用按钮必须不可点击且鼠标为等待光标;加载按钮显示明确进行中动词。
- 所有日期基于 Asia/Shanghai,设计数据锚点为 2026-08-30。
## 设计验收规则
每个页面必须在 1440 × 1024 同一视口完成“设计基准截图 / 实际页面截图”对照。P0–P2 的布局、padding、信息层级、文字、色彩、状态与响应式差异必须修复;最终记录写入项目根目录 `design-qa.md`。
+156
View File
@@ -0,0 +1,156 @@
import { createRequire } from "node:module";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const webURL = process.env.VISUAL_WEB_URL ?? "http://127.0.0.1:5173";
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const outputDirectory = resolve(process.cwd(), ".design-evidence/v074/implementation");
const designOutputDirectory = resolve(process.cwd(), ".design-evidence/v074/design");
const avatarFixture = resolve(process.cwd(), ".design-evidence/avatar-upload-fixture.png");
const sections = [
["home", "陪伴周记", "首页"],
["conversations", "回忆时间轴", "聊天记录"],
["stories", "故事与记忆", "故事与记忆"],
["subjects", "长者资料", "长者资料"],
["messages", "日程与提醒", "日程与提醒"],
["members", "家庭管理", "家庭管理"],
["devices", "陪伴设备", "陪伴设备"],
["sharing", "内容分享", "内容分享"],
["privacy", "数据与授权", "数据与授权"],
["billing", "服务用量", "服务用量"],
["models", "大模型管理", "大模型管理"],
["profile", "设置与权限", "设置与权限"],
];
await mkdir(outputDirectory, { recursive: true });
await mkdir(designOutputDirectory, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const designPage = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 });
for (const [section] of sections) {
await designPage.goto(`${webURL}/design-v074/index.html?section=${section}`, { waitUntil: "networkidle" });
await designPage.screenshot({ path: resolve(designOutputDirectory, `${section}.png`) });
}
await designPage.close();
const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 });
page.setDefaultTimeout(6000);
const consoleErrors = [];
const failedRequests = [];
const errorResponses = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("response", (response) => { if (response.status() >= 400) errorResponses.push(`${response.status()} ${response.url()}`); });
page.on("requestfailed", (request) => {
const reason = request.failure()?.errorText ?? "failed";
const pathname = new URL(request.url()).pathname;
if (request.method() === "GET" && pathname === "/api/v1/events" && reason.includes("ERR_ABORTED")) return;
if (pathname.startsWith("/api/v1/") && reason.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${reason}`);
});
const results = [];
try {
await page.goto(webURL, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /^登\s*录$/ }).click();
await page.waitForURL(/localhost:58180|127\.0\.0\.1:58180/);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
const webOriginPattern = new RegExp(`^${new URL(webURL).origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:/|$)`);
await page.waitForURL(webOriginPattern, { timeout: 15000 });
await page.waitForTimeout(1200);
await page.screenshot({ path: resolve(outputDirectory, "after-login.png") });
await page.locator(".customer-account-button").waitFor({ timeout: 15000 });
for (const [index, [section, heading, menuLabel]] of sections.entries()) {
if (index > 0) await page.getByRole("menuitem", { name: new RegExp(`${menuLabel}$`) }).click();
await page.getByRole("heading", { name: heading, exact: true, level: 1 }).waitFor({ timeout: 15000 });
if (section === "conversations") {
const firstConversation = page.locator("button.conversation-row").first();
if (await firstConversation.isVisible()) {
await firstConversation.click();
await page.waitForTimeout(700);
}
}
await page.waitForTimeout(250);
const layout = await page.evaluate(() => {
const header = document.querySelector(".customer-page__header")?.getBoundingClientRect();
const context = document.querySelector(".workspace__context-bar")?.getBoundingClientRect();
const content = document.querySelector(".workspace__context-bar")?.nextElementSibling?.getBoundingClientRect();
const overflow = [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.filter((element) => !element.classList.contains("ant-badge") && !element.classList.contains("family-member-card__avatar") && !element.closest(".usage-activity-card"))
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`);
return {
headerBottom: header?.bottom,
contextTop: context?.top,
contextBottom: context?.bottom,
contentTop: content?.top,
overflow,
pageHorizontalOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
customerOrganizationText: document.body.innerText.includes("客户组织"),
};
});
await page.screenshot({ path: resolve(outputDirectory, `${section}.png`) });
results.push({ section, heading, layout });
}
await page.getByRole("menuitem", { name: /长者资料$/ }).click();
await page.getByRole("heading", { name: "长者资料", exact: true, level: 1 }).waitFor();
await page.locator("button.subject-add-button").click();
const avatarInput = page.locator(".subject-avatar-upload input[type=file]");
await avatarInput.setInputFiles(avatarFixture);
await page.getByAltText("待上传的长者头像预览").waitFor();
const avatarPreviewVisible = await page.getByAltText("待上传的长者头像预览").isVisible();
await page.screenshot({ path: resolve(outputDirectory, "subjects-avatar-upload.png") });
const disabledCursorResult = await page.evaluate(() => {
const controls = [...document.querySelectorAll("button:disabled")];
return { count: controls.length, allWait: controls.length > 0 && controls.every((item) => getComputedStyle(item).cursor === "wait") };
});
await page.getByRole("button", { name: "关闭新增长者弹窗" }).click();
const modalChecks = [];
async function checkModal(menuLabel, triggerSelector, triggerName, dialogHeading, closeName = "关闭") {
try {
console.log(`Checking modal: ${menuLabel} / ${triggerName}`);
await page.getByRole("menuitem", { name: new RegExp(`${menuLabel}$`) }).click();
await page.locator(triggerSelector).first().click();
const dialog = page.getByRole("dialog").last();
await dialog.getByRole("heading", { name: dialogHeading, exact: true }).waitFor();
await dialog.getByRole("button", { name: closeName, exact: true }).click();
await dialog.waitFor({ state: "hidden" });
modalChecks.push({ menuLabel, triggerName, passed: true });
} catch (error) {
console.error(`Modal check failed: ${menuLabel} / ${triggerName}: ${String(error)}`);
modalChecks.push({ menuLabel, triggerName, passed: false, error: String(error) });
}
}
await checkModal("家庭管理", ".family-accept-button", "接受邀请", "接受家庭邀请");
await checkModal("家庭管理", ".family-member-card--invite", "邀请新成员", "邀请家庭成员");
await checkModal("陪伴设备", ".workspace--devices .workspace__actions button:has-text('绑定设备')", "绑定设备", "绑定新设备");
await checkModal("内容分享", ".subject-list-toolbar > button", "新建分享", "新建资源分享");
await checkModal("数据与授权", ".privacy-actions button:has-text('资料更正')", "资料更正", "提交资料更正");
await checkModal("大模型管理", ".model-active-hero > button", "添加大模型", "添加总结大模型");
await page.getByRole("menuitem", { name: /故事与记忆$/ }).click();
await page.locator("button.story-candidate-card").first().click();
const storyDialog = page.getByRole("dialog").last();
await storyDialog.getByRole("heading", { name: "故事详情", exact: true }).waitFor();
await storyDialog.getByRole("button", { name: "关闭", exact: true }).click();
modalChecks.push({ menuLabel: "故事与记忆", triggerName: "故事卡片", passed: true });
const familyNavigationVisible = await page.getByRole("menuitem", { name: /家庭管理/ }).isVisible();
const pageFailures = results.filter(({ layout }) => layout.pageHorizontalOverflow > 2 || layout.overflow.length || layout.customerOrganizationText || (layout.contextTop !== undefined && layout.headerBottom !== undefined && layout.contextTop - layout.headerBottom > 4));
const uniqueResponseErrors = [...new Set(errorResponses)].filter((item) => !item.includes("/api/v1/events") && !(/^403 .*\/media$/.test(item)));
const relevantConsoleErrors = consoleErrors.filter((item) => !item.startsWith("Failed to load resource:"));
const report = { results, avatarPreviewVisible, modalChecks, disabledCursorResult, familyNavigationVisible, pageFailures, consoleErrors: relevantConsoleErrors, errorResponses: uniqueResponseErrors, failedRequests };
console.log(JSON.stringify(report, null, 2));
if (!avatarPreviewVisible || modalChecks.some((item) => !item.passed) || !disabledCursorResult.allWait || !familyNavigationVisible || pageFailures.length || relevantConsoleErrors.length || uniqueResponseErrors.length || failedRequests.length) {
throw new Error("Customer portal v0.74 visual/function smoke failed");
}
} finally {
await browser.close();
}
File diff suppressed because one or more lines are too long
+298
View File
@@ -452,3 +452,301 @@ input[type="checkbox"], input[type="radio"], input[type="range"] { accent-color:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
/* Customer portal v0.74 — authoritative page shell and cross-page spacing. */
.customer-layout {
--v074-paper: #fbfaf7;
--v074-surface: #fffefd;
--v074-ink: #262525;
--v074-muted: #77726c;
--v074-line: #e6e0d8;
--v074-blue: #315d9b;
--v074-blue-soft: #edf3fb;
--v074-coral: #c45f3b;
--v074-coral-soft: #fff2ec;
--v074-green: #5f8f69;
--v074-green-soft: #edf6ef;
min-height: 100vh;
color: var(--v074-ink);
background: var(--v074-paper) !important;
}
.customer-header {
position: fixed !important;
z-index: 120 !important;
top: 0;
right: 0;
left: 0;
display: flex !important;
align-items: center !important;
height: 68px !important;
padding: 0 24px !important;
border-bottom: 1px solid var(--v074-line) !important;
background: rgba(255,254,252,.98) !important;
}
.customer-header__brand { flex: none; }
.customer-header__brand > strong { color: var(--v074-ink) !important; font-size: 21px !important; }
.customer-header__actions { display: flex !important; margin-left: auto; }
.customer-brand-mark { width: 34px !important; height: 34px !important; border: 1px solid var(--v074-ink) !important; color: var(--v074-ink) !important; background: #fff !important; }
.customer-search { width: min(360px,34vw) !important; }
.customer-search .ant-input-affix-wrapper,
.customer-search .ant-input-group-addon .ant-btn { height: 40px !important; border-color: var(--v074-line) !important; border-radius: 9px !important; background: #fff !important; }
.customer-layout__body { min-height: 100vh !important; padding-top: 68px !important; background: var(--v074-paper) !important; }
.customer-sider {
position: fixed !important;
z-index: 100 !important;
top: 68px !important;
bottom: 0 !important;
left: 0 !important;
display: flex !important;
height: calc(100dvh - 68px) !important;
border-right: 1px solid var(--v074-line) !important;
background: var(--v074-surface) !important;
}
.customer-sider .ant-layout-sider-children { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow: hidden auto; }
.customer-sider__title { flex: none; height: 50px !important; padding: 0 20px !important; color: var(--v074-muted) !important; }
.customer-sider .ant-menu { flex: 1; padding: 4px 10px 12px !important; overflow: visible !important; background: transparent !important; }
.customer-sider .ant-menu-item,
.customer-sider .ant-menu-submenu-title { min-height: 44px !important; height: 44px !important; margin: 2px 0 !important; padding-inline: 13px !important; border-radius: 9px !important; color: #4a4947 !important; font-size: 14px !important; line-height: 44px !important; }
.customer-sider .ant-menu-item-selected,
.customer-sider .ant-menu-submenu-selected > .ant-menu-submenu-title { color: var(--v074-blue) !important; background: var(--v074-blue-soft) !important; font-weight: 650; }
.customer-sider .ant-menu-sub.ant-menu-inline { background: transparent !important; }
.customer-sider .ant-menu-sub .ant-menu-item { padding-left: 44px !important; }
.customer-sider__help { flex: none; margin: 10px 14px 16px !important; padding: 12px !important; border: 1px solid var(--v074-line); border-radius: 10px !important; background: #faf7f2 !important; }
.customer-content { min-width: 0 !important; margin-left: 224px !important; background: var(--v074-paper) !important; transition: margin-left .18s ease; }
.customer-sider.ant-layout-sider-collapsed + .customer-content { margin-left: 72px !important; }
.customer-page.customer-page {
width: min(1360px,100%) !important;
max-width: 1360px !important;
margin: 0 auto !important;
padding: 0 28px 56px !important;
}
.customer-page > .ant-breadcrumb { display: none !important; }
.customer-page > .customer-page__header {
display: flex !important;
align-items: flex-start !important;
justify-content: space-between !important;
gap: 16px !important;
min-height: 0 !important;
margin: 0 !important;
padding: 24px 0 18px !important;
border-bottom: 1px solid var(--v074-line);
}
.customer-page__header h1 { margin: 0 0 7px !important; color: var(--v074-ink) !important; font-size: 28px !important; line-height: 1.2 !important; letter-spacing: -.03em; }
.customer-page__header p { margin: 0 !important; color: var(--v074-muted) !important; font-size: 14px !important; }
.customer-page__header .ant-tag { margin-top: 2px !important; border: 0 !important; border-radius: 999px !important; color: #467550 !important; background: var(--v074-green-soft) !important; }
.customer-page.customer-page > .workspace { padding: 0 !important; border: 0 !important; border-radius: 0 !important; background: transparent !important; box-shadow: none !important; }
.workspace__context-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 58px;
margin: 0 0 16px;
padding: 8px 10px;
border-bottom: 1px solid var(--v074-line);
flex-wrap: wrap;
}
.workspace__context-bar .household-tabs { flex: 1 1 280px; min-width: 0; }
.household-tabs { display: flex !important; align-items: center; gap: 8px !important; margin: 0 !important; padding: 0 !important; border: 0 !important; }
.household-tabs button {
flex: 0 0 auto;
min-width: 0 !important;
min-height: 38px !important;
height: 38px !important;
padding: 0 14px !important;
border: 1px solid var(--v074-line) !important;
border-radius: 9px !important;
color: var(--v074-ink) !important;
background: #fff !important;
box-shadow: none !important;
white-space: nowrap;
}
.household-tabs button.active { border-color: #e5d2bd !important; background: #f6ecdf !important; font-weight: 650; }
.household-tabs .household-tabs__add { flex: none; width: 38px !important; padding: 0 !important; }
.workspace__actions { display: flex !important; align-items: center !important; justify-content: flex-end !important; gap: 8px !important; flex: 1 1 auto; min-width: 0; margin: 0 !important; flex-wrap: wrap; }
.workspace__actions > button,
.workspace__actions > .button-secondary,
.workspace__actions select { min-height: 38px !important; height: 38px !important; border-radius: 9px !important; }
.workspace__actions > button:not(.button-secondary) { border-color: var(--v074-blue) !important; color: #fff !important; background: var(--v074-blue) !important; }
.workspace__actions .event-stream-status { white-space: nowrap; }
.customer-page--stories .workspace__actions { width: auto !important; flex: 0 1 auto !important; flex-wrap: nowrap !important; }
.customer-page--stories .workspace-story-subject {
display: grid !important;
grid-template-columns: auto 180px;
align-items: center;
gap: 8px;
width: auto !important;
margin: 0 !important;
}
.customer-page--stories .workspace-story-subject select { width: 180px !important; min-width: 180px !important; }
.workspace__header { margin: 0 0 16px !important; padding: 0 0 18px !important; }
.workspace__header h2 { font-size: 25px !important; }
.workspace__action-message,.workspace__inline-error,.workspace__readonly-note { margin: 0 0 14px !important; padding: 12px 14px !important; border-radius: 10px !important; }
.customer-layout button:disabled,
.customer-layout [role="button"][aria-disabled="true"] { cursor: wait !important; opacity: .52 !important; transform: none !important; }
/* Shared objects use one clear surface; avoid nested full-page cards. */
.customer-page .panel-section,
.customer-page .device-list-panel,
.customer-page .family-members-panel,
.customer-page .family-home-card,
.customer-page .family-activity-card,
.customer-page .subject-profile-card,
.customer-page .story-candidate-card,
.customer-page .confirmed-memory-card,
.customer-page .privacy-card,
.customer-page .privacy-exports,
.customer-page .model-config-card,
.customer-page .usage-overview__metrics,
.customer-page .usage-activity-card,
.customer-page .usage-overview__details > section {
border-color: var(--v074-line) !important;
border-radius: 14px !important;
background: var(--v074-surface) !important;
box-shadow: none !important;
}
.customer-page .section-title { min-height: 48px; padding: 14px 18px !important; border-bottom-color: var(--v074-line) !important; }
.customer-page .data-row,.customer-page .subject-data-row { min-height: 60px; padding: 13px 18px !important; border-bottom-color: var(--v074-line) !important; }
.customer-page input,.customer-page select,.customer-page textarea { border-color: var(--v074-line) !important; border-radius: 9px !important; background: #fff !important; }
.customer-page button { min-height: 38px; border-radius: 9px !important; box-shadow: none !important; }
.customer-page button:not(.ant-btn):focus-visible { outline: 2px solid var(--v074-blue); outline-offset: 2px; }
/* Family management */
.family-overview { display: grid; gap: 16px; }
.family-overview__head { align-items: center !important; margin: 0 !important; padding: 18px 20px !important; border: 1px solid var(--v074-line) !important; border-radius: 14px !important; background: var(--v074-surface) !important; }
.family-overview__identity > small { color: var(--v074-muted) !important; }
.family-overview__title-row { gap: 8px !important; }
.family-overview__title-row h2 { font-size: 22px !important; }
.family-overview__identity p { margin-bottom: 0 !important; }
.family-overview__layout { grid-template-columns: minmax(0,1.45fr) minmax(280px,.65fr) !important; gap: 16px !important; }
.family-members-panel { padding: 0 18px 18px !important; }
.family-member-grid { grid-template-columns: repeat(2,minmax(0,1fr)) !important; gap: 12px !important; }
.family-member-card { min-height: 112px !important; padding: 18px !important; border-color: var(--v074-line) !important; border-radius: 12px !important; background: #fff !important; }
.family-member-card--invite { border-style: dashed !important; }
.family-home-card__image { min-height: 116px !important; background: #8e8986 !important; }
.family-activity-card article { padding: 13px 16px !important; }
/* Elder profiles */
.subject-management { display: grid; gap: 16px; }
.subject-card-layout { display: block !important; }
.subject-card-grid { grid-template-columns: repeat(3,minmax(250px,1fr)) !important; gap: 14px !important; }
.subject-profile-card { min-height: 225px !important; padding: 20px !important; }
.subject-profile-card.active { border: 2px solid #36485f !important; }
.subject-profile-card__avatar { width: 58px !important; height: 58px !important; }
.subject-profile-card__facts { margin-top: 16px !important; border-radius: 11px !important; }
.subject-profile-card__footer { margin-top: 15px !important; padding-top: 14px !important; }
.subject-profile-card--add { border-style: dashed !important; }
.subject-avatar-upload { cursor: pointer !important; }
.subject-avatar-upload input,.subject-avatar-editor input { pointer-events: auto !important; cursor: pointer !important; }
.subject-create-modal { width: min(720px,calc(100vw - 32px)) !important; max-height: calc(100dvh - 32px) !important; }
.subject-create-modal__body { gap: 14px !important; padding: 20px 24px !important; }
/* Stories, sharing, privacy, and models */
.story-toolbar,.subject-list-toolbar,.privacy-toolbar { margin: 0 0 16px !important; border: 1px solid var(--v074-line) !important; border-radius: 14px !important; background: var(--v074-surface) !important; }
.story-candidate-grid { grid-template-columns: repeat(3,minmax(240px,1fr)) !important; gap: 14px !important; }
.story-candidate-card { min-height: 190px !important; padding: 18px !important; }
.story-candidate-card__footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: auto; }
.story-candidate-card__footer b { color: var(--v074-blue); font-size: 12px; font-weight: 650; }
.privacy-layout > .state-panel { min-height: 150px !important; padding: 28px !important; }
.privacy-layout { gap: 16px !important; }
.subject-list-toolbar { padding: 16px 18px !important; }
.privacy-toolbar { display: grid !important; grid-template-columns: minmax(160px,.55fr) minmax(250px,.8fr) minmax(420px,1.65fr) !important; align-items: end !important; gap: 16px !important; padding: 18px !important; }
.privacy-actions { display: flex !important; align-items: center !important; justify-content: flex-end !important; gap: 8px !important; flex-wrap: wrap !important; }
.privacy-actions button { min-height: 36px !important; }
.privacy-grid { grid-template-columns: repeat(2,minmax(0,1fr)) !important; gap: 14px !important; }
.model-management-page { gap: 16px !important; }
.model-active-hero { padding: 20px 22px !important; border-color: var(--v074-line) !important; border-radius: 14px !important; background: #f5f8f6 !important; }
.model-card-grid { gap: 14px !important; }
.model-config-card { padding: 20px !important; }
/* Device inventory */
.device-list-panel { padding: 0 18px 18px !important; }
.device-list-panel > .section-title { margin-inline: -18px !important; }
.device-card { overflow: hidden; border-color: var(--v074-line) !important; border-radius: 13px !important; background: #fff !important; box-shadow: none !important; }
.device-card__summary { min-height: 112px !important; padding: 18px 20px !important; }
.device-card__actions { padding: 14px 18px !important; border-left-color: var(--v074-line) !important; }
/* Reminder center replaces the undifferentiated message stack. */
.customer-message-panel.reminder-center { display: grid; gap: 16px; padding: 0 !important; border: 0 !important; background: transparent !important; }
.reminder-center__grid { display: grid; grid-template-columns: minmax(0,1.45fr) minmax(300px,.7fr); gap: 16px; }
.reminder-card { overflow: hidden; border: 1px solid var(--v074-line); border-radius: 14px; background: var(--v074-surface); }
.reminder-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 19px 20px 14px; border-bottom: 1px solid var(--v074-line); }
.reminder-card h3 { margin: 0; font-size: 20px; }
.reminder-card header p { margin: 6px 0 0; color: var(--v074-muted); font-size: 12px; }
.reminder-row { display: grid; grid-template-columns: 78px 14px minmax(0,1fr) auto; align-items: center; gap: 10px; min-height: 66px; padding: 12px 20px; border-bottom: 1px solid var(--v074-line); }
.reminder-row:last-child { border-bottom: 0; }
.reminder-row time,.reminder-row small { color: var(--v074-muted); font-size: 12px; }
.reminder-row__dot { width: 10px; height: 10px; border-radius: 50%; background: var(--v074-blue); }
.reminder-row p,.reminder-todo p { margin: 4px 0 0; color: var(--v074-muted); font-size: 12px; }
.reminder-count { display: grid; place-items: center; min-width: 28px; height: 28px; border-radius: 999px; color: var(--v074-coral); background: var(--v074-coral-soft); font-size: 12px; }
.reminder-todos { display: grid; }
.reminder-todo { display: grid; grid-template-columns: 10px minmax(0,1fr) auto; align-items: center; gap: 12px; min-height: 72px; padding: 13px 18px; border-bottom: 1px solid var(--v074-line); }
.reminder-todo:last-child { border-bottom: 0; }
.reminder-todo > span { width: 8px; height: 8px; border-radius: 50%; background: var(--v074-blue); }
.reminder-todo--warning > span { background: var(--v074-coral); }
.reminder-todo small { color: var(--v074-blue); white-space: nowrap; }
.reminder-empty { display: grid; place-items: center; gap: 8px; min-height: 190px; padding: 28px; color: var(--v074-muted); text-align: center; }
.reminder-empty .anticon { color: #9aa9b6; font-size: 30px; }
.reminder-empty strong { color: var(--v074-ink); }
.reminder-history__summary { display: grid; grid-template-columns: repeat(4,1fr); }
.reminder-history__summary > div { padding: 18px 20px; border-right: 1px solid var(--v074-line); }
.reminder-history__summary > div:last-child { border-right: 0; }
.reminder-history__summary strong { display: block; font-size: 24px; }
.reminder-history__summary span { color: var(--v074-muted); font-size: 12px; }
/* Usage: keep activity close to the page header and remove the oversized wrapper. */
.usage-overview { display: grid !important; gap: 16px !important; margin: 0 !important; }
.usage-overview__toolbar { padding: 0 !important; }
.usage-overview__toolbar > div:first-child { display: none !important; }
.usage-overview__filters { margin-left: auto !important; }
.usage-overview__metrics { margin: 0 !important; }
.usage-activity-card { margin: 0 !important; padding: 18px 20px !important; }
.usage-activity-card > header { padding: 0 0 12px !important; }
/* Profile and permissions */
.customer-profile { gap: 16px !important; padding: 0 !important; }
.customer-profile__context { margin-bottom: 0 !important; }
.customer-profile__context .household-tabs { flex: 1 1 auto; }
.customer-profile > .ant-row { margin-inline: 0 !important; }
.customer-profile .ant-card { border-color: var(--v074-line) !important; border-radius: 14px !important; background: var(--v074-surface) !important; box-shadow: none !important; }
.permission-matrix-card { margin: 0 !important; }
.permission-matrix th,.permission-matrix td { padding: 11px 14px !important; border-bottom-color: var(--v074-line) !important; }
.permission-matrix thead th { background: #faf7f2 !important; }
@media (max-width: 1180px) {
.subject-card-grid { grid-template-columns: repeat(2,minmax(240px,1fr)) !important; }
.story-candidate-grid { grid-template-columns: repeat(2,minmax(240px,1fr)) !important; }
.privacy-toolbar { grid-template-columns: minmax(160px,.55fr) minmax(260px,1fr) !important; }
.privacy-actions { grid-column: 1 / -1; justify-content: flex-start !important; }
.family-overview__layout,.reminder-center__grid { grid-template-columns: 1fr !important; }
}
@media (max-width: 900px) {
.customer-header { padding-inline: 14px !important; }
.customer-sider { top: 68px !important; width: 224px !important; min-width: 224px !important; max-width: 224px !important; height: calc(100dvh - 68px) !important; transform: translateX(0); }
.customer-sider.ant-layout-sider-collapsed { transform: translateX(-100%); }
.customer-content,.customer-sider.ant-layout-sider-collapsed + .customer-content { margin-left: 0 !important; }
.customer-page.customer-page { padding-inline: 20px !important; }
.workspace__context-bar { align-items: stretch; flex-direction: column; }
.workspace__actions { justify-content: flex-start !important; flex-wrap: wrap; }
.family-member-grid,.privacy-grid,.model-card-grid { grid-template-columns: 1fr !important; }
}
@media (max-width: 700px) {
.customer-header { height: 58px !important; }
.customer-layout__body { padding-top: 58px !important; }
.customer-sider { top: 58px !important; height: calc(100dvh - 58px) !important; }
.customer-page.customer-page { padding: 0 14px 44px !important; }
.customer-page > .customer-page__header { padding: 18px 0 14px !important; }
.customer-page__header h1 { font-size: 24px !important; }
.customer-page__header .ant-tag { display: none; }
.workspace__context-bar { margin-bottom: 12px; padding-inline: 0; }
.workspace__actions .event-stream-status { display: none; }
.subject-card-grid,.story-candidate-grid,.privacy-toolbar { grid-template-columns: 1fr !important; }
.privacy-actions { grid-column: auto; }
.reminder-row { grid-template-columns: 62px 12px minmax(0,1fr); }
.reminder-row small { display: none; }
.reminder-history__summary { grid-template-columns: repeat(2,1fr); }
.reminder-history__summary > div:nth-child(2) { border-right: 0; }
.reminder-history__summary > div:nth-child(-n+2) { border-bottom: 1px solid var(--v074-line); }
}
+14 -7
View File
@@ -53,14 +53,14 @@ type PortalSection = CustomerSection | "profile";
const sectionMeta: Record<PortalSection, { title: string; description: string; breadcrumb: string }> = {
home: { title: "陪伴周记", description: "从每位长者出发,查看最近聊天、心情线索和需要家人关心的变化。", breadcrumb: "首页" },
members: { title: "家人共享", description: "管理家庭成员、邀请、角色和家庭空间。", breadcrumb: "家人共享" },
members: { title: "家庭管理", description: "管理家庭空间、成员邀请、角色和所有权。", breadcrumb: "家庭管理" },
subjects: { title: "长者资料", description: "维护长期陪伴资料、交流节奏、话题偏好和分项授权。", breadcrumb: "长者资料" },
devices: { title: "设备管理", description: "绑定陪伴设备,查看在线状态、固件版本和音量配置。", breadcrumb: "设备管理" },
conversations: { title: "聊天记录", description: "按长者和日期回看聊天摘要、话题、心情线索与完整内容。", breadcrumb: "聊天记录" },
devices: { title: "陪伴设备", description: "绑定设备并查看在线状态、固件、音量和诊断结果。", breadcrumb: "陪伴设备" },
conversations: { title: "回忆时间轴", description: "以时间串起每一次聊天,查看 AI 摘要、关键词、故事候选与下次话题。", breadcrumb: "聊天记录" },
stories: { title: "故事与记忆", description: "核对智能整理的故事,沉淀经过家庭确认的长期记忆。", breadcrumb: "故事与记忆" },
sharing: { title: "资源分享", description: "面向指定家人分享故事、文字或录音,并随时撤销权限。", breadcrumb: "授权与隐私 / 资源分享" },
privacy: { title: "隐私请求", description: "申请访问、导出或删除个人数据,并跟踪处理状态。", breadcrumb: "授权与隐私 / 隐私请求" },
billing: { title: "用量与费用", description: "查看本家庭及设备的通话时长;存在已核价记录时可切换费用。", breadcrumb: "用量与费用" },
sharing: { title: "内容分享", description: "向指定家人分享故事、文字或录音,并随时撤销权限。", breadcrumb: "隐私与授权 / 内容分享" },
privacy: { title: "数据与授权", description: "按项目查看授权,并申请访问、更正、撤回、删除或私有导出。", breadcrumb: "隐私与授权 / 数据与授权" },
billing: { title: "服务用量", description: "围绕长者聊天查看时长、活跃日和最近通话。", breadcrumb: "服务用量" },
messages: { title: "日程与提醒", description: "查看陪伴动态、设备状态和需要处理的家庭提醒。", breadcrumb: "日程与提醒" },
models: { title: "大模型管理", description: "配置、测试并激活用于聊天总结、关键词和故事候选生成的大模型。", breadcrumb: "设置与权限 / 大模型管理" },
profile: { title: "设置与权限", description: "查看登录身份、家庭角色和全部权限范围。", breadcrumb: "设置与权限" }
@@ -86,7 +86,7 @@ export function CustomerPortal() {
{ key: "stories", icon: <BookOutlined />, label: "故事与记忆" },
{ key: "subjects", icon: <HeartOutlined />, label: "长者资料" },
{ key: "messages", icon: <MessageOutlined />, label: "日程与提醒" },
{ key: "members", icon: <TeamOutlined />, label: "家人共享" },
{ key: "members", icon: <TeamOutlined />, label: "家庭管理" },
{ type: "divider" },
{ key: "devices", icon: <LaptopOutlined />, label: "陪伴设备" },
{
@@ -234,7 +234,14 @@ function CustomerLogin({ onLogin }: { onLogin: () => void }) {
}
function CustomerProfile({ currentUser, onLogout }: { currentUser: NonNullable<ReturnType<typeof useAuth>["currentUser"]>; onLogout: () => void }) {
const memberships = currentUser.accounts.flatMap((account) => account.household_memberships);
return <div className="customer-profile">
<div className="workspace__context-bar customer-profile__context">
<nav className="household-tabs" role="tablist" aria-label="当前家庭">
{memberships.length ? memberships.map((membership, index) => <button type="button" role="tab" aria-selected={index === 0} className={index === 0 ? "active" : ""} key={`${membership.household_id}-${index}`}>{membership.household_name}</button>) : <button type="button" role="tab" aria-selected="true" className="active">尚未加入家庭</button>}
</nav>
<span className="event-stream-status event-stream-status--connected">权限实时校验</span>
</div>
<Card>
<div className="customer-profile__identity"><Avatar size={64} icon={<UserOutlined />} /><div><h2>{currentUser.identity.display_name || currentUser.identity.preferred_username}</h2><p>{currentUser.identity.preferred_username}</p></div><Button onClick={onLogout}>退出登录</Button></div>
</Card>
+24 -17
View File
@@ -1308,16 +1308,11 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
return (
<section className={`workspace workspace--${tab}`} aria-label="家庭业务工作台">
<div className="workspace__context-bar">
{state === "ready" && households.length ? <nav className="household-tabs" role="tablist" aria-label="切换家庭">
{households.map((household) => <button type="button" role="tab" aria-selected={household.id === selectedHouseholdID} className={household.id === selectedHouseholdID ? "active" : ""} key={household.id} onClick={() => setSelectedHouseholdID(household.id)}>{household.name}</button>)}
<button type="button" className="household-tabs__add" aria-label="新增家庭" onClick={() => { setNewHouseholdTenantID(selectedHousehold?.tenant_id ?? accounts[0]?.tenant_id ?? ""); setHouseholdCreateOpen(true); }}><PlusOutlined /></button>
</nav> : null}
<div className="workspace__header">
<div>
<p className="eyebrow">FAMILY COMPANION</p>
<h2>{customerSectionTitle(tab)}</h2>
<p>{customerSectionDescription(tab)}</p>
</div>
</nav> : <span />}
<div className="workspace__actions">
{tab === "stories" ? <label className="workspace-story-subject">
<span>长者</span>
@@ -1332,6 +1327,13 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
{tab === "devices" && canManageDevice ? <button type="button" onClick={() => setDeviceClaimOpen(true)}><PlusOutlined /> 绑定设备</button> : null}
</div>
</div>
{showNavigation ? <div className="workspace__header">
<div>
<p className="eyebrow">FAMILY COMPANION</p>
<h2>{customerSectionTitle(tab)}</h2>
<p>{customerSectionDescription(tab)}</p>
</div>
</div> : null}
{invitationAcceptOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setInvitationAcceptOpen(false); }}><form className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="accept-invitation-title" onSubmit={(event) => { void acceptInvitation(event); setInvitationAcceptOpen(false); }}><header><div><h3 id="accept-invitation-title">接受家庭邀请</h3><p>先预览家庭、角色和有效期,确认后才会加入。</p></div><button type="button" aria-label="关闭" onClick={() => setInvitationAcceptOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>一次性邀请凭据</span><input autoFocus value={acceptInvitationToken} minLength={32} maxLength={128} autoComplete="off" onChange={(event) => setAcceptInvitationToken(event.target.value)} placeholder="粘贴一次性邀请凭据" /><small>凭据长度为 32 至 128 个字符。</small></label></div><footer><button type="button" className="button-secondary" onClick={() => setInvitationAcceptOpen(false)}>取消</button><button type="submit" disabled={actionBusy || acceptInvitationToken.trim().length < 32 || acceptInvitationToken.trim().length > 128}>{actionBusy ? "正在核验…" : "预览并接受"}</button></footer></form></div> : null}
@@ -1569,19 +1571,24 @@ function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequ
const pendingStories = stories.filter((item) => item.status === "CANDIDATE" || item.status === "IN_REVIEW");
const activePrivacy = privacyRequests.filter((item) => !["COMPLETED", "REJECTED", "FAILED"].includes(item.status));
const derived = [
...offline.map((item) => ({ id: `offline-${item.id}`, title: "设备当前离线", detail: `${item.subject_name} · ${item.model}`, tone: "warning" })),
...pendingStories.map((item) => ({ id: `story-${item.id}`, title: "故事等待家庭确认", detail: item.title, tone: "info" })),
...activePrivacy.map((item) => ({ id: `privacy-${item.id}`, title: "隐私请求处理中", detail: `${item.request_type} · ${item.status}`, tone: "info" }))
...pendingStories.map((item) => ({ id: `story-${item.id}`, title: "故事等待家庭确认", detail: item.title, tone: "info", action: "去审核" })),
...offline.map((item) => ({ id: `offline-${item.id}`, title: "设备当前离线", detail: `${item.subject_name} · ${item.model}`, tone: "warning", action: "查看设备" })),
...activePrivacy.map((item) => ({ id: `privacy-${item.id}`, title: "数据请求处理中", detail: `${privacyRequestTypeLabel(item.request_type)} · ${privacyStatusLabel(item.status)}`, tone: "info", action: "查看进度" }))
];
return <section className="customer-message-panel">
<div className="panel-title"><div><p className="eyebrow">MESSAGE CENTER</p><h3>消息中心</h3><p>这里只展示状态提醒,不在通知中携带转写、录音、故事正文或隐私原因。</p></div><span className={`event-stream-status event-stream-status--${eventState}`}>{eventState === "connected" ? "实时连接" : eventState === "reconnecting" ? "重新连接" : "正在连接"}</span></div>
{derived.length ? <div className="message-list">{derived.map((item) => <article key={item.id} className={`message-item message-item--${item.tone}`}><span aria-hidden="true" /><div><strong>{item.title}</strong><p>{item.detail}</p></div></article>)}</div> : <p className="workspace__readonly-note">当前没有需要处理的家庭提醒。</p>}
<div className="message-timeline"><h4>本次登录收到的实时事件</h4>{notices.length ? notices.map((notice) => <article key={notice.id}><time>{formatDate(notice.occurredAt)}</time><div><strong>{eventTypeLabel(notice.type)}</strong><p>{resourceTypeLabel(notice.resourceType)} · {notice.status}</p></div></article>) : <p>暂未收到新的状态事件。服务端事件到达后会自动出现在这里。</p>}</div>
const today = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" }).format(new Date());
return <section className="customer-message-panel reminder-center">
<div className="reminder-center__grid">
<section className="reminder-card reminder-card--today"><header><div><h3>今天的陪伴动态</h3><p>{today} · 聊天、故事和设备状态会自动汇总</p></div><span className={`event-stream-status event-stream-status--${eventState}`}>{eventState === "connected" ? "实时连接" : eventState === "reconnecting" ? "重新连接" : "正在连接"}</span></header>
{notices.length ? notices.slice(0, 5).map((notice) => <article className="reminder-row" key={notice.id}><time>{formatDate(notice.occurredAt)}</time><span className="reminder-row__dot" aria-hidden="true" /><div><strong>{eventTypeLabel(notice.type)}</strong><p>{resourceTypeLabel(notice.resourceType)} · {notice.status}</p></div><small>已记录</small></article>) : <div className="reminder-empty"><ClockCircleOutlined /><strong>今天还没有新的实时动态</strong><span>新聊天、故事整理和设备状态变化会显示在这里。</span></div>}
</section>
<aside className="reminder-card"><header><div><h3>需要家人处理</h3><p>不展示转写、录音正文或隐私原因</p></div><span className="reminder-count">{derived.length}</span></header>{derived.length ? <div className="reminder-todos">{derived.map((item) => <article key={item.id} className={`reminder-todo reminder-todo--${item.tone}`}><span aria-hidden="true" /><div><strong>{item.title}</strong><p>{item.detail}</p></div><small>{item.action}</small></article>)}</div> : <div className="reminder-empty"><CheckCircleOutlined /><strong>当前没有待处理提醒</strong><span>所有家庭事项都已处理。</span></div>}</aside>
</div>
<section className="reminder-card reminder-history"><header><div><h3>近期记录</h3><p>按发生时间集中查看家庭陪伴状态</p></div></header><div className="reminder-history__summary"><div><strong>{pendingStories.length}</strong><span>待确认故事</span></div><div><strong>{offline.length}</strong><span>离线设备</span></div><div><strong>{activePrivacy.length}</strong><span>处理中数据请求</span></div><div><strong>{notices.length}</strong><span>本次实时事件</span></div></div></section>
</section>;
}
function customerSectionTitle(section: Tab) {
return ({ home: "陪伴周记", members: "家人共享", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "隐私与授权", billing: "服务用量", messages: "日程与提醒", models: "大模型管理" } as Record<Tab, string>)[section];
return ({ home: "陪伴周记", members: "家庭管理", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "数据与授权", billing: "服务用量", messages: "日程与提醒", models: "大模型管理" } as Record<Tab, string>)[section];
}
function customerSectionDescription(section: Tab) {
@@ -2212,7 +2219,7 @@ function StoryPanel({ selectedID, stories, memories, selected, loading, actionBu
useEffect(() => { setSelectedMemoryID(undefined); setDetailOpen(false); setMemoryQuery(""); }, [selectedID]);
return <div className="story-layout story-board">
<section className="story-candidates"><div className="story-board__section-title"><h3>故事候选 <span>{stories.length}</span></h3></div><div className="story-candidate-grid">
{stories.map((story) => <button type="button" className={!selectedMemory && selected?.id === story.id ? "story-candidate-card active" : "story-candidate-card"} key={story.id} onClick={() => { setSelectedMemoryID(undefined); setDetailOpen(true); void onOpen(story.id); }}><span className="story-candidate-card__status"><HourglassOutlined /> {storyStatusLabel(story.status)}</span><strong>{story.title}</strong><small><TagOutlined /> v{story.revision_no}</small></button>)}
{stories.map((story) => <button type="button" className={!selectedMemory && selected?.id === story.id ? "story-candidate-card active" : "story-candidate-card"} key={story.id} onClick={() => { setSelectedMemoryID(undefined); setDetailOpen(true); void onOpen(story.id); }}><span className="story-candidate-card__status"><HourglassOutlined /> {storyStatusLabel(story.status)}</span><strong>{story.title}</strong><span className="story-candidate-card__footer"><small><TagOutlined /> v{story.revision_no}</small><b>开始审核</b></span></button>)}
{!stories.length ? <div className="story-board-empty story-board-empty--compact"><span><BookOutlined /></span><h3>暂无故事候选</h3><p>通话整理出的候选会显示在这里,等待家庭成员审核。</p></div> : null}
</div></section>
<section className="confirmed-memories"><div className="story-board__section-title story-board__section-title--bordered"><h3>已确认记忆 <small>{visibleMemories.length}/{memories.length}</small></h3></div><label className="memory-search-field"><SearchOutlined /><input value={memoryQuery} maxLength={200} onChange={(event) => setMemoryQuery(event.target.value)} placeholder="搜索当前长者的记忆" /></label>
@@ -2407,7 +2414,7 @@ function PrivacyPanel({ subjects, selectedID, onSelect, requests, exports, conse
{deleteConfirmation && deleteConfirmation.trim() !== selectedSubject.preferred_name ? <p className="privacy-delete-confirmation__error" role="alert">称呼必须与“{selectedSubject.preferred_name}”完全一致。</p> : null}
<div className="privacy-delete-confirmation__actions"><button type="button" className="button-secondary" disabled={actionBusy} onClick={() => { setDeleteOpen(false); setDeleteReason(""); setDeleteConfirmation(""); }}>取消</button><button type="submit" className="button-danger" disabled={actionBusy || !deleteReason.trim() || deleteConfirmation.trim() !== selectedSubject.preferred_name}>{actionBusy ? "正在执行删除对账…" : "确认永久删除"}</button></div>
</form></div> : null}
{correctionOpen && selectedSubject ? <div className="workspace-dialog-backdrop" role="presentation"><form className="workspace-dialog" aria-label="资料更正申请" onSubmit={(event) => void submitCorrection(event)}><header><div><h3>提交资料更正</h3><p>更正会生成新的档案版本,不改写历史通话快照。</p></div><button type="button" aria-label="关闭" onClick={() => setCorrectionOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>称呼</span><input autoFocus required maxLength={120} value={correctionName} onChange={(event) => setCorrectionName(event.target.value)} /></label><label><span>语言</span><input required minLength={2} maxLength={20} value={correctionLanguage} onChange={(event) => setCorrectionLanguage(event.target.value)} /></label><label><span>时区</span><input required maxLength={100} value={correctionTimezone} onChange={(event) => setCorrectionTimezone(event.target.value)} placeholder="Asia/Shanghai" /></label><p className="workspace-dialog__notice">隐私请求和审计只记录更正字段名称,不复制档案正文。</p></div><footer><button type="button" className="button-secondary" onClick={() => setCorrectionOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !correctionName.trim() || !correctionLanguage.trim() || !correctionTimezone.trim()}>{actionBusy ? "正在更正…" : "确认更正"}</button></footer></form></div> : null}
{correctionOpen && selectedSubject ? <div className="workspace-dialog-backdrop" role="presentation"><form className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="privacy-correction-title" onSubmit={(event) => void submitCorrection(event)}><header><div><h3 id="privacy-correction-title">提交资料更正</h3><p>更正会生成新的档案版本,不改写历史通话快照。</p></div><button type="button" aria-label="关闭" onClick={() => setCorrectionOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>称呼</span><input autoFocus required maxLength={120} value={correctionName} onChange={(event) => setCorrectionName(event.target.value)} /></label><label><span>语言</span><input required minLength={2} maxLength={20} value={correctionLanguage} onChange={(event) => setCorrectionLanguage(event.target.value)} /></label><label><span>时区</span><input required maxLength={100} value={correctionTimezone} onChange={(event) => setCorrectionTimezone(event.target.value)} placeholder="Asia/Shanghai" /></label><p className="workspace-dialog__notice">隐私请求和审计只记录更正字段名称,不复制档案正文。</p></div><footer><button type="button" className="button-secondary" onClick={() => setCorrectionOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !correctionName.trim() || !correctionLanguage.trim() || !correctionTimezone.trim()}>{actionBusy ? "正在更正…" : "确认更正"}</button></footer></form></div> : null}
{withdrawalOpen ? <div className="workspace-dialog-backdrop" role="presentation"><form className="workspace-dialog" aria-label="批量撤回授权" onSubmit={(event) => void submitWithdrawal(event)}><header><div><h3>批量撤回授权</h3><p>撤回立即生效,依赖这些授权的活动通话会安全停止。</p></div><button type="button" aria-label="关闭" onClick={() => setWithdrawalOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><fieldset className="privacy-consent-selection"><legend>选择要撤回的当前有效授权</legend>{effectiveConsentTypes.map((type) => <label key={type}><input type="checkbox" checked={withdrawalTypes.includes(type)} onChange={(event) => setWithdrawalTypes(event.target.checked ? [...withdrawalTypes, type] : withdrawalTypes.filter((item) => item !== type))} /><span>{consentTypeLabel(type)}</span></label>)}</fieldset><p className="workspace-dialog__notice">个性化记忆授权撤回后,已有记忆会停用并清除可召回向量。</p></div><footer><button type="button" className="button-secondary" onClick={() => setWithdrawalOpen(false)}>取消</button><button type="submit" className="button-danger" disabled={actionBusy || withdrawalTypes.length === 0}>{actionBusy ? "正在撤回…" : `确认撤回 ${withdrawalTypes.length} 项`}</button></footer></form></div> : null}
{loading && !requests.length ? <StatePanel title="正在读取隐私请求" detail="同步后台子任务和盘点结果…" /> : requests.length ? <div className="privacy-grid">{requests.map((request) => {
const counts = (request.result_summary.inventory_counts ?? {}) as Record<string, number>;