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" />
- } aria-label="打开消息中心" onClick={() => navigate("messages")} />
+ } aria-label="打开提醒与待办" onClick={() => navigate("messages")} />
{auth.currentUser || designPreview ?
;
}
-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
-
- {notices.length ? notices.slice(0, 5).map((notice) => {eventTypeLabel(notice.type)}{resourceTypeLabel(notice.resourceType)} · {notice.status}
已记录) : 今天还没有新的实时动态新聊天、故事整理和设备状态变化会显示在这里。
}
+
+ {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);