@@ -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 ) ;