This commit is contained in:
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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: "陪伴设备" },
|
||||
{
|
||||
@@ -158,7 +158,7 @@ export function CustomerPortal() {
|
||||
|
||||
<Layout className="customer-layout__body">
|
||||
<Layout.Sider className="customer-sider" width={224} collapsedWidth={72} collapsed={collapsed} trigger={null}>
|
||||
<div className="customer-sider__title"><HeartOutlined />{collapsed ? null : <span>长者陪伴</span>}</div>
|
||||
<div className="customer-sider__title"><HeartOutlined />{collapsed ? null : <span>长者陪伴</span>}</div>
|
||||
<Menu mode="inline" selectedKeys={[section]} defaultOpenKeys={["privacy-group"]} items={menuItems} onClick={({ key }) => navigate(key as PortalSection)} />
|
||||
<div className="customer-sider__help"><SafetyCertificateOutlined />{collapsed ? null : <div><strong>隐私受保护</strong><span>仅展示当前家庭数据</span></div>}</div>
|
||||
</Layout.Sider>
|
||||
@@ -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>
|
||||
|
||||
@@ -1308,16 +1308,11 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
|
||||
|
||||
return (
|
||||
<section className={`workspace workspace--${tab}`} aria-label="家庭业务工作台">
|
||||
{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>
|
||||
<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> : <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>;
|
||||
|
||||
Reference in New Issue
Block a user