fix: make reminder actions use real resources

This commit is contained in:
Codex Local
2026-08-30 21:22:21 +08:00
parent dcf0fc0d7f
commit 78e0a19acb
7 changed files with 103 additions and 31 deletions
+4 -2
View File
@@ -15,6 +15,7 @@ var ErrCursorUnavailable = errors.New("web event cursor is unavailable")
type Event struct {
ID int64 `json:"id"`
Type string `json:"type"`
HouseholdID string `json:"household_id"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
SubjectID string `json:"subject_id,omitempty"`
@@ -65,9 +66,10 @@ func (s *Service) ListAfter(ctx context.Context, principal identity.Principal, c
limit = 100
}
rows, err := s.pool.Query(ctx, `
SELECT we.id,we.event_type,we.resource_type,we.resource_public_id::text,
SELECT we.id,we.event_type,h.public_id::text,we.resource_type,we.resource_public_id::text,
COALESCE(sp.public_id::text,''),we.status,we.occurred_at
FROM web_event we
JOIN household h ON h.id=we.household_id AND h.tenant_id=we.tenant_id
JOIN household_member hm ON hm.tenant_id=we.tenant_id AND hm.household_id=we.household_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=we.tenant_id AND ua.status='active'
LEFT JOIN subject_profile sp ON sp.id=we.subject_id AND sp.tenant_id=we.tenant_id
@@ -80,7 +82,7 @@ func (s *Service) ListAfter(ctx context.Context, principal identity.Principal, c
items := make([]Event, 0)
for rows.Next() {
var item Event
if err := rows.Scan(&item.ID, &item.Type, &item.ResourceType, &item.ResourceID, &item.SubjectID, &item.Status, &item.OccurredAt); err != nil {
if err := rows.Scan(&item.ID, &item.Type, &item.HouseholdID, &item.ResourceType, &item.ResourceID, &item.SubjectID, &item.Status, &item.OccurredAt); err != nil {
return nil, err
}
items = append(items, item)
@@ -67,7 +67,7 @@ func TestServiceCursorIsolationCleanupAndPresenceExpiry(t *testing.T) {
t.Fatalf("cross-user cursor error=%v", err)
}
items, err := service.ListAfter(ctx, owner, ids[0], 1)
if err != nil || len(items) != 1 || items[0].ID != ids[1] || items[0].SubjectID != "10000000-0000-0000-0000-000000000411" {
if err != nil || len(items) != 1 || items[0].ID != ids[1] || items[0].HouseholdID != "10000000-0000-0000-0000-000000000211" || items[0].SubjectID != "10000000-0000-0000-0000-000000000411" {
t.Fatalf("events=%+v err=%v", items, err)
}
allItems, err := service.ListAfter(ctx, owner, 0, 0)
+2 -1
View File
@@ -4597,10 +4597,11 @@ components:
WebEvent:
type: object
additionalProperties: false
required: [id, type, resource_type, resource_id, status, occurred_at]
required: [id, type, household_id, resource_type, resource_id, status, occurred_at]
properties:
id: { type: integer, format: int64 }
type: { type: string, enum: [device.presence.changed, conversation.started, conversation.completed, conversation.failed, story.created, story.revised, media.upload.completed, export.ready, privacy_request.updated, device.command.updated] }
household_id: { type: string, format: uuid }
resource_type: { type: string }
resource_id: { type: string, format: uuid }
subject_id: { type: string, format: uuid }
+2
View File
@@ -4202,6 +4202,8 @@ export interface components {
id: number;
/** @enum {string} */
type: "device.presence.changed" | "conversation.started" | "conversation.completed" | "conversation.failed" | "story.created" | "story.revised" | "media.upload.completed" | "export.ready" | "privacy_request.updated" | "device.command.updated";
/** Format: uuid */
household_id: string;
resource_type: string;
/** Format: uuid */
resource_id: string;
+9 -6
View File
@@ -673,15 +673,17 @@ input[type="checkbox"], input[type="radio"], input[type="range"] { accent-color:
.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 { display: grid; grid-template-columns: 78px 14px minmax(0,1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 66px; padding: 12px 20px; border: 0; border-bottom: 1px solid var(--v074-line); border-radius: 0; color: var(--v074-ink); background: transparent; text-align: left; box-shadow: none; }
.reminder-row:last-child { border-bottom: 0; }
.reminder-row:hover,.reminder-row:focus-visible { background: #f8fafb; }
.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 { display: grid; grid-template-columns: 10px minmax(0,1fr) auto; align-items: center; gap: 12px; width: 100%; min-height: 72px; padding: 13px 18px; border: 0; border-bottom: 1px solid var(--v074-line); border-radius: 0; color: var(--v074-ink); background: transparent; text-align: left; box-shadow: none; }
.reminder-todo:last-child { border-bottom: 0; }
.reminder-todo:hover,.reminder-todo:focus-visible { background: #faf8f5; }
.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; }
@@ -689,8 +691,9 @@ input[type="checkbox"], input[type="radio"], input[type="range"] { accent-color:
.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 > div,.reminder-history__summary > button { padding: 18px 20px; border: 0; border-right: 1px solid var(--v074-line); border-radius: 0; color: var(--v074-ink); background: transparent; text-align: left; box-shadow: none; }
.reminder-history__summary > button:hover,.reminder-history__summary > button:focus-visible { background: #f8fafb; }
.reminder-history__summary > :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; }
@@ -747,6 +750,6 @@ input[type="checkbox"], input[type="radio"], input[type="range"] { accent-color:
.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); }
.reminder-history__summary > :nth-child(2) { border-right: 0; }
.reminder-history__summary > :nth-child(-n+2) { border-bottom: 1px solid var(--v074-line); }
}
+3 -3
View File
@@ -61,7 +61,7 @@ const sectionMeta: Record<PortalSection, { title: string; description: string; b
sharing: { title: "内容分享", description: "向指定家人分享故事、文字或录音,并随时撤销权限。", breadcrumb: "隐私与授权 / 内容分享" },
privacy: { title: "数据与授权", description: "按项目查看授权,并申请访问、更正、撤回、删除或私有导出。", breadcrumb: "隐私与授权 / 数据与授权" },
billing: { title: "服务用量", description: "围绕长者聊天查看时长、活跃日和最近通话。", breadcrumb: "服务用量" },
messages: { title: "日程与提醒", description: "查看陪伴动态、设备状态和需要处理的家庭提醒。", breadcrumb: "日程与提醒" },
messages: { title: "提醒与待办", description: "查看服务端记录的陪伴动态,并处理故事、设备和隐私事项。", breadcrumb: "提醒与待办" },
models: { title: "大模型管理", description: "配置、测试并激活用于聊天总结、关键词和故事候选生成的大模型。", breadcrumb: "设置与权限 / 大模型管理" },
profile: { title: "设置与权限", description: "查看登录身份、家庭角色和全部权限范围。", breadcrumb: "设置与权限" }
};
@@ -85,7 +85,7 @@ export function CustomerPortal() {
{ key: "conversations", icon: <CommentOutlined />, label: "聊天记录" },
{ key: "stories", icon: <BookOutlined />, label: "故事与记忆" },
{ key: "subjects", icon: <HeartOutlined />, label: "长者资料" },
{ key: "messages", icon: <MessageOutlined />, label: "日程与提醒" },
{ key: "messages", icon: <MessageOutlined />, label: "提醒与待办" },
{ key: "members", icon: <TeamOutlined />, label: "家庭管理" },
{ type: "divider" },
{ key: "devices", icon: <LaptopOutlined />, label: "陪伴设备" },
@@ -145,7 +145,7 @@ export function CustomerPortal() {
</div>
<div className="customer-header__actions">
<Input.Search value={searchValue} onChange={(event) => setSearchValue(event.target.value)} onSearch={search} prefix={<SearchOutlined />} placeholder="搜索长者、设备、通话" allowClear className="customer-search" />
<Tooltip title="消息中心"><Badge dot><Button type="text" icon={<BellOutlined />} aria-label="打开消息中心" onClick={() => navigate("messages")} /></Badge></Tooltip>
<Tooltip title="提醒与待办"><Badge dot><Button type="text" icon={<BellOutlined />} aria-label="打开提醒与待办" onClick={() => navigate("messages")} /></Badge></Tooltip>
{auth.currentUser || designPreview ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight">
<Button type="text" className="customer-account-button">
<Avatar size={36} src={designPreview ? "/assets/family-chen.png" : undefined} icon={<UserOutlined />} />
+82 -18
View File
@@ -82,6 +82,7 @@ type Tab = CustomerSection | "firmware";
type LoadState = "idle" | "loading" | "ready" | "error";
type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error";
type EventStreamState = "connecting" | "connected" | "reconnecting";
type WebEventNotice = { id: string; type: string; householdID: string; resourceType: string; resourceID: string; subjectID?: string; status: string; occurredAt: string };
type ConversationPace = "slow" | "normal" | "lively";
type DialoguePreset = {
key: "daily" | "memory" | "interest";
@@ -219,6 +220,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const [firmwareReleaseImpact, setFirmwareReleaseImpact] = useState<FirmwareReleaseImpact>();
const [firmwareImpactLoading, setFirmwareImpactLoading] = useState(false);
const [selectedStory, setSelectedStory] = useState<Story>();
const [storyReviewRequestID, setStoryReviewRequestID] = useState<string>();
const [detailLoading, setDetailLoading] = useState(false);
const [refreshRevision, setRefreshRevision] = useState(0);
const [actionBusy, setActionBusy] = useState(false);
@@ -269,7 +271,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const [rollbackReleaseID, setRollbackReleaseID] = useState("");
const [eventStreamState, setEventStreamState] = useState<EventStreamState>("connecting");
const [lastEventAt, setLastEventAt] = useState<string>();
const [eventNotices, setEventNotices] = useState<Array<{ id: string; type: string; resourceType: string; status: string; occurredAt: string }>>([]);
const [eventNotices, setEventNotices] = useState<WebEventNotice[]>([]);
const eventRefreshTimer = useRef<number | undefined>(undefined);
const lastEventRefreshAt = useRef(0);
const conversationDetailController = useRef<AbortController | undefined>(undefined);
@@ -278,6 +280,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
useEffect(() => setTab(section), [section]);
function selectSection(next: CustomerSection) {
if (next !== "stories") setStoryReviewRequestID(undefined);
setTab(next);
onSectionChange?.(next);
}
@@ -380,11 +383,14 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
if (data) {
setLastEventAt(new Date().toISOString());
try {
const item = JSON.parse(data) as { id?: number; type?: string; resource_type?: string; status?: string; occurred_at?: string };
const item = JSON.parse(data) as { id?: number; type?: string; household_id?: string; resource_type?: string; resource_id?: string; subject_id?: string; status?: string; occurred_at?: string };
setEventNotices((current) => [{
id: String(item.id ?? id),
type: item.type ?? "resource.updated",
householdID: item.household_id ?? "",
resourceType: item.resource_type ?? "resource",
resourceID: item.resource_id ?? "",
subjectID: item.subject_id,
status: item.status ?? "UPDATED",
occurredAt: item.occurred_at ?? new Date().toISOString()
}, ...current.filter((notice) => notice.id !== String(item.id ?? id))].slice(0, 50));
@@ -1005,6 +1011,41 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
}
}
async function openStoryReminder(story: Story) {
setSelectedSubjectID(story.subject_id);
setStoryReviewRequestID(story.id);
selectSection("stories");
await openStory(story.id);
}
async function openEventNotice(notice: WebEventNotice) {
if (notice.subjectID) setSelectedSubjectID(notice.subjectID);
switch (notice.resourceType) {
case "story":
if (notice.resourceID) setStoryReviewRequestID(notice.resourceID);
selectSection("stories");
if (notice.resourceID) await openStory(notice.resourceID);
return;
case "conversation":
selectSection("conversations");
if (notice.resourceID) await openConversation(notice.resourceID);
return;
case "upload_batch":
selectSection("conversations");
return;
case "privacy_request":
case "export_task":
selectSection("privacy");
return;
case "device":
case "device_command":
selectSection("devices");
return;
default:
selectSection("home");
}
}
async function reviseStory(story: Story, content: string, reason: string, errorType?: ContentErrorType) {
await runAction("故事已保存为新的人工修订版本。", async () => {
const response = await platformApi.reviseStory(story, content, reason, accessToken, errorType);
@@ -1470,7 +1511,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
) : null}
{tab === "models" ? <ContentModelPanel models={contentModels} loading={modelLoading} error={modelError} canManage={canManageModels} actionBusy={actionBusy} onCreate={createContentModel} onTest={testContentModel} onActivate={activateContentModel} onRevoke={revokeContentModel} /> : null}
{tab === "stories" ? <StoryPanel selectedID={selectedSubjectID}
stories={stories} memories={memories} selected={selectedStory} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory}
stories={stories} memories={memories} selected={selectedStory} openRequestID={storyReviewRequestID} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory}
onOpen={openStory} onOpenConversation={openStoryConversation} onRevise={reviseStory} onConfirm={confirmStory} onReject={rejectStory} onUpdateFact={updateFactCandidate} onUpdateMemory={updateMemory} onDeleteMemory={deleteMemory} /> : null}
{tab === "sharing" && canManageSharing ? <SharingPanel subjects={subjects} selectedID={selectedSubjectID} onSelect={setSelectedSubjectID}
members={members} stories={stories} conversations={conversations} media={conversationMedia} grants={sharingGrants}
@@ -1482,7 +1523,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
{tab === "privacy" && !canReadPrivacy ? <StatePanel title="当前角色不能查看隐私请求" detail="家庭查看者没有隐私请求读取权限,请联系家庭所有者或管理员。" /> : null}
{tab === "sharing" && !canManageSharing ? <StatePanel title="当前角色不能管理资源分享" detail="仅家庭所有者或管理员可以创建、撤销故事、文字和录音分享。" /> : null}
{tab === "billing" ? <FamilyUsageOverview accessToken={accessToken} household={selectedHousehold} devices={devices.filter((item) => item.household_id === selectedHousehold.id)} /> : null}
{tab === "messages" ? <CustomerMessagePanel notices={eventNotices} devices={devices.filter((item) => item.household_id === selectedHousehold.id)} presence={presence} stories={stories} privacyRequests={privacyRequests} eventState={eventStreamState} /> : null}
{tab === "messages" ? <CustomerMessagePanel notices={eventNotices.filter((item) => item.householdID === selectedHousehold.id)} devices={devices.filter((item) => item.household_id === selectedHousehold.id)} presence={presence} stories={stories} privacyRequests={privacyRequests} eventState={eventStreamState} onNavigate={selectSection} onReviewStory={openStoryReminder} onOpenNotice={openEventNotice} /> : null}
</>
) : null}
</section>
@@ -1561,36 +1602,41 @@ function CustomerHomePanel({ subject, subjects, devices, presence, conversations
</div>;
}
function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequests, eventState }: {
notices: Array<{ id: string; type: string; resourceType: string; status: string; occurredAt: string }>;
function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequests, eventState, onNavigate, onReviewStory, onOpenNotice }: {
notices: WebEventNotice[];
devices: ManagedDevice[];
presence: Record<string, DevicePresence>;
stories: Story[];
privacyRequests: PrivacyRequest[];
eventState: EventStreamState;
onNavigate: (section: CustomerSection) => void;
onReviewStory: (story: Story) => Promise<void>;
onOpenNotice: (notice: WebEventNotice) => Promise<void>;
}) {
const offline = devices.filter((item) => presence[item.id] && !presence[item.id].online);
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 = [
...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: "查看进度" }))
...pendingStories.map((item) => ({ id: `story-${item.id}`, title: "故事等待家庭确认", detail: item.title, tone: "info", action: "去审核", run: () => onReviewStory(item) })),
...offline.map((item) => ({ id: `offline-${item.id}`, title: "设备当前离线", detail: `${item.subject_name} · ${item.model}`, tone: "warning", action: "查看设备", run: () => onNavigate("devices") })),
...activePrivacy.map((item) => ({ id: `privacy-${item.id}`, title: "数据请求处理中", detail: `${privacyRequestTypeLabel(item.request_type)} · ${privacyStatusLabel(item.status)}`, tone: "info", action: "查看进度", run: () => onNavigate("privacy") }))
];
const today = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" }).format(new Date());
const now = new Date();
const today = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" }).format(now);
const todayNotices = notices.filter((notice) => sameLocalDate(new Date(notice.occurredAt), now));
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 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>
{todayNotices.length ? todayNotices.slice(0, 8).map((notice) => <button type="button" className="reminder-row" key={notice.id} onClick={() => void onOpenNotice(notice)}><time>{formatDate(notice.occurredAt)}</time><span className="reminder-row__dot" aria-hidden="true" /><div><strong>{eventTypeLabel(notice.type)}</strong><p>{resourceTypeLabel(notice.resourceType)} · {eventStatusLabel(notice.status)}</p></div><small>查看详情 ›</small></button>) : <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>
<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) => <button type="button" key={item.id} className={`reminder-todo reminder-todo--${item.tone}`} onClick={() => void item.run()}><span aria-hidden="true" /><div><strong>{item.title}</strong><p>{item.detail}</p></div><small>{item.action} ›</small></button>)}</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 className="reminder-card reminder-history"><header><div><h3>当前业务状态</h3><p>数量直接来自业务接口,不使用演示日程或前端占位数据</p></div></header><div className="reminder-history__summary"><button type="button" onClick={() => onNavigate("stories")}><strong>{pendingStories.length}</strong><span>待确认故事</span></button><button type="button" onClick={() => onNavigate("devices")}><strong>{offline.length}</strong><span>离线设备</span></button><button type="button" onClick={() => onNavigate("privacy")}><strong>{activePrivacy.length}</strong><span>处理中数据请求</span></button><div><strong>{todayNotices.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) {
@@ -1605,7 +1651,7 @@ function customerSectionDescription(section: Tab) {
sharing: "按资源和权限范围向指定家庭成员分享内容。",
privacy: "提交访问、导出或删除申请并跟踪处理进度。",
billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。",
messages: "集中查看陪伴动态、设备、故事和隐私请求的状态提醒。",
messages: "集中查看服务端记录的陪伴动态,并处理设备、故事和隐私请求待办。",
models: "配置、测试并激活用于聊天摘要、关键词和故事候选生成的大模型。"
} as Record<Tab, string>)[section];
}
@@ -1618,6 +1664,18 @@ function resourceTypeLabel(type: string) {
return ({ device: "设备", conversation: "通话", story: "故事", upload_batch: "媒体", export_task: "导出任务", privacy_request: "隐私请求", device_command: "设备指令" } as Record<string, string>)[type] ?? "业务资源";
}
function eventStatusLabel(status: string) {
return ({
ACTIVE: "进行中", COMPLETED: "已完成", FAILED: "失败", CANDIDATE: "待审核", IN_REVIEW: "审核中", CONFIRMED: "已确认", REJECTED: "已拒绝",
ONLINE: "在线", OFFLINE: "离线", READY: "已就绪", PENDING: "待处理", PROCESSING: "处理中", SUCCEEDED: "已成功", CANCELLED: "已取消",
CREATED: "已创建", UPDATED: "已更新", UPLOADED: "已上传"
} as Record<string, string>)[status.toUpperCase()] ?? status;
}
function sameLocalDate(left: Date, right: Date) {
return !Number.isNaN(left.getTime()) && left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate();
}
function MemberPanel({ household, members, invitations, canManage, canTransfer, actionBusy, inviteeHint, onInviteeHint, inviteRole, onInviteRole, issuedToken, onDismissToken, onCreateInvitation, onUpdateRole, onRemove, onRevoke, onTransfer, onAddHousehold, onAcceptInvitation, onRenameHousehold }: {
household: Household; households: Household[]; members: HouseholdMember[]; invitations: MemberInvitation[]; canManage: boolean; canTransfer: boolean; actionBusy: boolean;
inviteeHint: string; onInviteeHint: (value: string) => void; inviteRole: "admin" | "editor" | "viewer"; onInviteRole: (value: "admin" | "editor" | "viewer") => void;
@@ -2228,9 +2286,9 @@ function RecordingMediaBlock({ retentionMode, media, state, error, grants, membe
</section>;
}
function StoryPanel({ selectedID, stories, memories, selected, loading, actionBusy, canReview, onOpen, onOpenConversation, onRevise, onConfirm, onReject, onUpdateFact, onUpdateMemory, onDeleteMemory }: {
function StoryPanel({ selectedID, stories, memories, selected, openRequestID, loading, actionBusy, canReview, onOpen, onOpenConversation, onRevise, onConfirm, onReject, onUpdateFact, onUpdateMemory, onDeleteMemory }: {
selectedID?: string; stories: Story[]; memories: CuratedMemory[];
selected?: Story; loading: boolean; actionBusy: boolean; canReview: boolean; onOpen: (id: string) => Promise<void>;
selected?: Story; openRequestID?: string; loading: boolean; actionBusy: boolean; canReview: boolean; onOpen: (id: string) => Promise<void>;
onOpenConversation: (story: Story) => Promise<void>;
onRevise: (story: Story, content: string, reason: string, errorType?: ContentErrorType) => Promise<void>;
onConfirm: (story: Story, reason: string) => Promise<void>; onReject: (story: Story, reason: string, errorType?: ContentErrorType) => Promise<void>;
@@ -2245,6 +2303,12 @@ function StoryPanel({ selectedID, stories, memories, selected, loading, actionBu
const [selectedMemoryID, setSelectedMemoryID] = useState<string>();
const [detailOpen, setDetailOpen] = useState(false);
useEffect(() => { setContent(selected?.content ?? ""); setReason(""); setErrorType(""); }, [selected]);
useEffect(() => {
if (openRequestID && selected?.id === openRequestID) {
setSelectedMemoryID(undefined);
setDetailOpen(true);
}
}, [openRequestID, selected?.id]);
const reviewable = canReview && (selected?.status === "CANDIDATE" || selected?.status === "IN_REVIEW");
const visibleMemories = memories.filter((memory) => memory.content.toLocaleLowerCase().includes(memoryQuery.trim().toLocaleLowerCase()));
const selectedMemory = memories.find((memory) => memory.id === selectedMemoryID);