From 78e0a19acb5d188e09f2bd6147ec79be0acb6e07 Mon Sep 17 00:00:00 2001 From: Codex Local Date: Sun, 30 Aug 2026 21:22:21 +0800 Subject: [PATCH] fix: make reminder actions use real resources --- internal/webevents/service.go | 6 +- .../webevents/service_integration_test.go | 2 +- openapi/openapi.yaml | 3 +- web/src/api/schema.d.ts | 2 + web/src/cal-theme.css | 15 +-- web/src/pages/CustomerPortal.tsx | 6 +- web/src/pages/SaaSWorkspace.tsx | 100 ++++++++++++++---- 7 files changed, 103 insertions(+), 31 deletions(-) diff --git a/internal/webevents/service.go b/internal/webevents/service.go index c79724c..ca65c96 100644 --- a/internal/webevents/service.go +++ b/internal/webevents/service.go @@ -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) diff --git a/internal/webevents/service_integration_test.go b/internal/webevents/service_integration_test.go index 7dc5f3c..c9c250b 100644 --- a/internal/webevents/service_integration_test.go +++ b/internal/webevents/service_integration_test.go @@ -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) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 9823441..44720a6 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -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 } diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index bc2d0bd..d0cc3f3 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -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; diff --git a/web/src/cal-theme.css b/web/src/cal-theme.css index 7789ce7..e133721 100644 --- a/web/src/cal-theme.css +++ b/web/src/cal-theme.css @@ -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); } } diff --git a/web/src/pages/CustomerPortal.tsx b/web/src/pages/CustomerPortal.tsx index 7f9edbb..20a5a4d 100644 --- a/web/src/pages/CustomerPortal.tsx +++ b/web/src/pages/CustomerPortal.tsx @@ -61,7 +61,7 @@ const sectionMeta: Record, label: "聊天记录" }, { key: "stories", icon: , label: "故事与记忆" }, { key: "subjects", icon: , label: "长者资料" }, - { key: "messages", icon: , label: "日程与提醒" }, + { key: "messages", icon: , label: "提醒与待办" }, { key: "members", icon: , label: "家庭管理" }, { type: "divider" }, { key: "devices", icon: , label: "陪伴设备" }, @@ -145,7 +145,7 @@ export function CustomerPortal() {
setSearchValue(event.target.value)} onSearch={search} prefix={} placeholder="搜索长者、设备、通话" allowClear className="customer-search" /> -
; } -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; stories: Story[]; privacyRequests: PrivacyRequest[]; eventState: EventStreamState; + onNavigate: (section: CustomerSection) => void; + onReviewStory: (story: Story) => Promise; + onOpenNotice: (notice: WebEventNotice) => Promise; }) { 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
-

今天的陪伴动态

{today} · 聊天、故事和设备状态会自动汇总

{eventState === "connected" ? "实时连接" : eventState === "reconnecting" ? "重新连接" : "正在连接"}
- {notices.length ? notices.slice(0, 5).map((notice) =>
) :
今天还没有新的实时动态新聊天、故事整理和设备状态变化会显示在这里。
} +

今天的系统动态

{today} · 仅显示服务端实际产生的状态事件

{eventState === "connected" ? "实时连接" : eventState === "reconnecting" ? "重新连接" : "正在连接"}
+ {todayNotices.length ? todayNotices.slice(0, 8).map((notice) => ) :
今天还没有新的系统动态完成通话、产生故事、设备变化或隐私请求更新后才会出现。
}
- +
-

近期记录

按发生时间集中查看家庭陪伴状态

{pendingStories.length}待确认故事
{offline.length}离线设备
{activePrivacy.length}处理中数据请求
{notices.length}本次实时事件
+

当前业务状态

数量直接来自业务接口,不使用演示日程或前端占位数据

{todayNotices.length}今天实时事件
; } function customerSectionTitle(section: Tab) { - return ({ home: "陪伴周记", members: "家庭管理", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "数据与授权", billing: "服务用量", messages: "日程与提醒", models: "大模型管理" } as Record)[section]; + return ({ home: "陪伴周记", members: "家庭管理", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "数据与授权", billing: "服务用量", messages: "提醒与待办", models: "大模型管理" } as Record)[section]; } function customerSectionDescription(section: Tab) { @@ -1605,7 +1651,7 @@ function customerSectionDescription(section: Tab) { sharing: "按资源和权限范围向指定家庭成员分享内容。", privacy: "提交访问、导出或删除申请并跟踪处理进度。", billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。", - messages: "集中查看陪伴动态、设备、故事和隐私请求的状态提醒。", + messages: "集中查看服务端记录的陪伴动态,并处理设备、故事和隐私请求待办。", models: "配置、测试并激活用于聊天摘要、关键词和故事候选生成的大模型。" } as Record)[section]; } @@ -1618,6 +1664,18 @@ function resourceTypeLabel(type: string) { return ({ device: "设备", conversation: "通话", story: "故事", upload_batch: "媒体", export_task: "导出任务", privacy_request: "隐私请求", device_command: "设备指令" } as Record)[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)[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 ; } -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; + selected?: Story; openRequestID?: string; loading: boolean; actionBusy: boolean; canReview: boolean; onOpen: (id: string) => Promise; onOpenConversation: (story: Story) => Promise; onRevise: (story: Story, content: string, reason: string, errorType?: ContentErrorType) => Promise; onConfirm: (story: Story, reason: string) => Promise; onReject: (story: Story, reason: string, errorType?: ContentErrorType) => Promise; @@ -2245,6 +2303,12 @@ function StoryPanel({ selectedID, stories, memories, selected, loading, actionBu const [selectedMemoryID, setSelectedMemoryID] = useState(); 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);