@@ -5,7 +5,6 @@ import {
CloseOutlined ,
DisconnectOutlined ,
DeleteOutlined ,
DownOutlined ,
EditOutlined ,
HeartOutlined ,
HomeOutlined ,
@@ -18,11 +17,13 @@ import {
SafetyCertificateOutlined ,
SearchOutlined ,
SettingOutlined ,
SmileOutlined ,
StopOutlined ,
TagOutlined ,
ToolOutlined ,
UserAddOutlined ,
UserOutlined
UserOutlined ,
UploadOutlined ,
} from "@ant-design/icons" ;
import {
APIRequestError ,
@@ -76,6 +77,22 @@ type LoadState = "idle" | "loading" | "ready" | "error";
type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error" ;
type EventStreamState = "connecting" | "connected" | "reconnecting" ;
type ConversationPace = "slow" | "normal" | "lively" ;
type DialoguePreset = {
key : "daily" | "memory" | "interest" ;
label : string ;
description : string ;
goal : string ;
pace : ConversationPace ;
preferredTopics : string [ ] ;
avoidedTopics : string [ ] ;
} ;
type ConversationInsight = {
summary : string ;
topics : string [ ] ;
mood : "positive" | "steady" | "attention" ;
moodLabel : string ;
evidence : string ;
} ;
type RecordingSegment = ConversationMedia [ "segments" ] [ number ] ;
type RecordingGrantInput = { grantee_user_id : string ; permission : "PLAY" | "DOWNLOAD" ; purpose : string ; expires_at : string } ;
type SharingGrantInput = { resource_type : "STORY" | "TRANSCRIPT" | "RECORDING" ; resource_id : string ; grantee_user_id : string ; permission : "VIEW_TEXT" | "LISTEN" | "DOWNLOAD" | "EDIT" ; purpose : string ; expires_at : string ; login_required : boolean } ;
@@ -116,6 +133,24 @@ type FamilyControlDraft =
| { kind : "sharing_grant_create" ; subjectID : string ; subjectName : string ; granteeName : string ; resourceLabel : string ; input : SharingGrantInput ; acknowledged : boolean ; confirmation : string }
| { kind : "sharing_revoke" ; item : SharingGrant ; acknowledged : boolean ; confirmation : string } ;
const dialoguePresets : DialoguePreset [ ] = [
{
key : "daily" , label : "日常陪伴" , description : "从当天生活和轻松小事自然开场" ,
goal : "轻松聊聊当天的生活,让长者感到有人陪伴" , pace : "slow" ,
preferredTopics : [ "天气" , "饮食" , "睡眠" , "花草" , "电视节目" ] , avoidedTopics : [ "疾病" , "家庭矛盾" , "收入" ]
} ,
{
key : "memory" , label : "回忆往事" , description : "温和引导年轻时的经历和重要人物" ,
goal : "温和引导长者回忆年轻时的工作和生活经历" , pace : "slow" ,
preferredTopics : [ "家乡" , "童年" , "工作经历" , "老朋友" , "节日" ] , avoidedTopics : [ "遗憾经历" , "家庭矛盾" , "经济压力" ]
} ,
{
key : "interest" , label : "兴趣聊天" , description : "围绕熟悉爱好展开低压力交流" ,
goal : "围绕长者喜欢的兴趣进行轻松交流" , pace : "normal" ,
preferredTopics : [ "戏曲" , "养花" , "做饭" , "散步" , "孙辈" ] , avoidedTopics : [ "争议新闻" , "疾病" , "家庭矛盾" ]
}
] ;
export function SaaSWorkspace ( { accessToken , accounts , section = "home" , onSectionChange , showNavigation = true , enableFirmware = false } : {
accessToken : string ;
accounts : CurrentUser [ "accounts" ] ;
@@ -156,6 +191,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const [ conversationUsage , setConversationUsage ] = useState < ConversationUsage > ( ) ;
const [ turns , setTurns ] = useState < ConversationTurn [ ] > ( [ ] ) ;
const [ contentSummaries , setContentSummaries ] = useState < ConversationContentSummary [ ] > ( [ ] ) ;
const [ conversationInsights , setConversationInsights ] = useState < Record < string , ConversationInsight > > ( { } ) ;
const [ conversationMedia , setConversationMedia ] = useState < ConversationMedia > ( ) ;
const [ mediaState , setMediaState ] = useState < MediaLoadState > ( "idle" ) ;
const [ mediaError , setMediaError ] = useState < string > ( ) ;
@@ -189,6 +225,8 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const [ newSubjectPace , setNewSubjectPace ] = useState < ConversationPace > ( "slow" ) ;
const [ newSubjectTopics , setNewSubjectTopics ] = useState ( "" ) ;
const [ newSubjectAvoidedTopics , setNewSubjectAvoidedTopics ] = useState ( "" ) ;
const [ newSubjectAvatar , setNewSubjectAvatar ] = useState ( "" ) ;
const [ newSubjectAvatarError , setNewSubjectAvatarError ] = useState ( "" ) ;
const [ subjectCreateOpen , setSubjectCreateOpen ] = useState ( false ) ;
const [ householdCreateOpen , setHouseholdCreateOpen ] = useState ( false ) ;
const [ invitationAcceptOpen , setInvitationAcceptOpen ] = useState ( false ) ;
@@ -436,6 +474,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
useEffect ( ( ) = > {
if ( ! selectedSubjectID ) {
setConversations ( [ ] ) ;
setConversationInsights ( { } ) ;
setConversationNextCursor ( undefined ) ;
setConversationError ( undefined ) ;
setConversationLoading ( false ) ;
@@ -465,6 +504,29 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
return ( ) = > controller . abort ( ) ;
} , [ accessToken , conversationEngineMode , conversationFrom , conversationStatus , conversationTo , refreshRevision , selectedSubjectID ] ) ;
useEffect ( ( ) = > {
const recent = conversations . filter ( ( item ) = > item . status === "COMPLETED" ) . slice ( 0 , 7 ) ;
if ( ! recent . length ) {
setConversationInsights ( { } ) ;
return ;
}
const controller = new AbortController ( ) ;
void Promise . all ( recent . map ( async ( conversation ) = > {
try {
const response = await platformApi . conversationSummaries ( conversation . id , accessToken , controller . signal ) ;
return [ conversation . id , inferConversationInsight ( response . data . items ) ] as const ;
} catch ( cause ) {
if ( cause instanceof DOMException && cause . name === "AbortError" ) throw cause ;
return [ conversation . id , inferConversationInsight ( [ ] ) ] as const ;
}
} ) ) . then ( ( items ) = > {
if ( ! controller . signal . aborted ) setConversationInsights ( Object . fromEntries ( items ) ) ;
} ) . catch ( ( cause ) = > {
if ( ! ( cause instanceof DOMException && cause . name === "AbortError" ) ) setConversationInsights ( { } ) ;
} ) ;
return ( ) = > controller . abort ( ) ;
} , [ accessToken , conversations ] ) ;
useEffect ( ( ) = > {
if ( devices . length === 0 ) { setPresence ( { } ) ; setTwins ( { } ) ; return ; }
const controller = new AbortController ( ) ;
@@ -498,8 +560,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const policyVersion = accounts . find ( ( account ) = > account . tenant_id === selectedHousehold ? . tenant_id ) ? . policy_version ? ? "" ;
const consentNotice = ` 我已阅读隐私政策 ${ policyVersion || "当前" } 版本,并同意对后续新通话启用“ ${ consentLabel ( newConsentType ) } ”。 ${ consentImpactLabel ( newConsentType ) } 我知道该授权可以单独撤回。 ` ;
const selectedConsentAlreadyEffective = consents . some ( ( item ) = > item . type === newConsentType && item . effective ) ;
const effectiveConsents = consents . filter ( ( item ) = > item . effective ) . length ;
const confirmedFacts = facts . filter ( ( item ) = > item . confirmation_status === "confirmed" ) . length ;
useEffect ( ( ) = > { setFamilyControlDraft ( undefined ) ; setClaimCode ( "" ) ; setAcceptInvitationToken ( "" ) ; } , [ tab , selectedHouseholdID , selectedSubjectID , selectedConversationID ] ) ;
@@ -726,7 +786,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
household : selectedHousehold ,
input : {
preferred_name : newSubjectName.trim ( ) , language : "zh-CN" , timezone : "Asia/Shanghai" ,
profile_data : { background : newSubjectBackground.trim ( ) , interests : splitContextList ( newSubjectInterests ) } ,
profile_data : { background : newSubjectBackground.trim ( ) , interests : splitContextList ( newSubjectInterests ) , . . . ( newSubjectAvatar ? { avatar_data_url : newSubjectAvatar } : { } ) } ,
conversation_preferences : { conversation_goal : newSubjectGoal.trim ( ) , pace : newSubjectPace , preferred_topics : splitContextList ( newSubjectTopics ) , avoided_topics : splitContextList ( newSubjectAvoidedTopics ) }
} ,
acknowledged : false ,
@@ -734,6 +794,24 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
} ) ;
}
async function selectNewSubjectAvatar ( file? : File ) {
if ( ! file ) return ;
setNewSubjectAvatarError ( "" ) ;
try {
setNewSubjectAvatar ( await prepareAvatarDataURL ( file ) ) ;
} catch ( cause ) {
setNewSubjectAvatar ( "" ) ;
setNewSubjectAvatarError ( errorMessage ( cause ) ) ;
}
}
function applyNewSubjectDialoguePreset ( preset : DialoguePreset ) {
setNewSubjectGoal ( preset . goal ) ;
setNewSubjectPace ( preset . pace ) ;
setNewSubjectTopics ( preset . preferredTopics . join ( "、" ) ) ;
setNewSubjectAvoidedTopics ( preset . avoidedTopics . join ( "、" ) ) ;
}
function updateSubjectProfile ( subject : Subject , input : UpdateSubjectRequest ) {
openFamilyControl ( { kind : "subject_update" , item : subject , input , acknowledged : false , confirmation : "" } ) ;
}
@@ -1120,8 +1198,8 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
if ( ! draft || ! familyControlReady ( draft ) ) return ;
let completed = false ;
if ( draft . kind === "household_create" ) completed = await runAction ( ` 家庭“ ${ draft . householdName } ”已创建,当前账号已成为家庭所有者。 ` , async ( ) = > { const created = await platformApi . createHousehold ( draft . tenantID , draft . householdName , accessToken ) ; const refreshed = await platformApi . households ( accessToken ) ; setHouseholds ( refreshed . data . items ) ; setSelectedHouseholdID ( created . data . id ) ; setNewHouseholdName ( "" ) ; setState ( "ready" ) ; } ) ;
else if ( draft . kind === "invitation_accept" ) completed = await runAction ( "邀请已接受,新的客户组织账号和 家庭成员关系已经生效。" , async ( ) = > { await platformApi . acceptInvitation ( draft . token , accessToken ) ; setAcceptInvitationToken ( "" ) ; await loadRoot ( ) ; } ) ;
else if ( draft . kind === "subject_create" ) completed = await runAction ( "长者档案已创建;后续新通话将按档案版本冻结所列背景和交流偏好。" , async ( ) = > { const response = await platformApi . createSubject ( draft . household . id , draft . input , accessToken ) ; setSelectedSubjectID ( response . data . id ) ; setNewSubjectName ( "" ) ; setNewSubjectBackground ( "" ) ; setNewSubjectInterests ( "" ) ; setNewSubjectGoal ( "" ) ; setNewSubjectPace ( "slow" ) ; setNewSubjectTopics ( "" ) ; setNewSubjectAvoidedTopics ( "" ) ; } ) ;
else if ( draft . kind === "invitation_accept" ) completed = await runAction ( "邀请已接受,新的家庭成员关系已经生效。" , async ( ) = > { await platformApi . acceptInvitation ( draft . token , accessToken ) ; setAcceptInvitationToken ( "" ) ; await loadRoot ( ) ; } ) ;
else if ( draft . kind === "subject_create" ) completed = await runAction ( "长者档案已创建;后续新通话将按档案版本冻结所列背景和交流偏好。" , async ( ) = > { const response = await platformApi . createSubject ( draft . household . id , draft . input , accessToken ) ; setSelectedSubjectID ( response . data . id ) ; setNewSubjectName ( "" ) ; setNewSubjectBackground ( "" ) ; setNewSubjectInterests ( "" ) ; setNewSubjectGoal ( "" ) ; setNewSubjectPace ( "slow" ) ; setNewSubjectTopics ( "" ) ; setNewSubjectAvoidedTopics ( "" ) ; setNewSubjectAvatar ( "" ) ; setNewSubjectAvatarError ( "" ) ; } ) ;
else if ( draft . kind === "fact_create" ) completed = await runAction ( "稳定事实候选已登记,仍需人工核对后才会作为已确认背景使用。" , async ( ) = > { await platformApi . createFact ( draft . subjectID , draft . input , accessToken ) ; setNewFactContent ( "" ) ; setNewFactSensitivity ( "normal" ) ; } ) ;
else if ( draft . kind === "subject_update" ) completed = await runAction ( "长者档案新版本已保存;后续新通话将冻结新版本,当前和历史通话保持原快照。" , async ( ) = > { const response = await platformApi . updateSubject ( draft . item , draft . input , accessToken ) ; setSubjects ( ( current ) = > current . map ( ( item ) = > item . id === response . data . id ? response.data : item ) ) ; } ) ;
else if ( draft . kind === "device_twin_update" ) completed = await runAction ( "新的设备音量期望配置已保存,等待设备领取并上报实际配置。" , async ( ) = > { const response = await platformApi . updateDeviceTwin ( draft . item . id , draft . twin . desired_revision , draft . input , accessToken ) ; setTwins ( ( current ) = > ( { . . . current , [ draft . item . id ] : response . data } ) ) ; } ) ;
@@ -1178,6 +1256,10 @@ 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 >
@@ -1192,12 +1274,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
< / select >
< / label > : null }
< span className = { ` event-stream-status event-stream-status-- ${ eventStreamState } ` } title = { lastEventAt ? ` 最近事件: ${ formatDate ( lastEventAt ) } ` : "等待状态事件" } > { eventStreamState === "connected" ? "状态实时更新" : eventStreamState === "reconnecting" ? "状态重连中" : "状态连接中" } < / span >
{ tab !== "members" ? < label >
< span > 当 前 家 庭 < / span >
< select value = { selectedHouseholdID ? ? "" } onChange = { ( event ) = > setSelectedHouseholdID ( event . target . value ) } >
{ households . map ( ( household ) = > < option key = { household . id } value = { household . id } > { household . name } < / option > ) }
< / select >
< / label > : null }
< button type = "button" className = "button-secondary" onClick = { ( ) = > void loadRoot ( ) } disabled = { state === "loading" } > < ReloadOutlined / > 刷 新 数 据 < / button >
{ tab === "subjects" && canWriteSubject ? < button type = "button" className = "subject-add-button" onClick = { ( ) = > setSubjectCreateOpen ( true ) } > < PlusOutlined / > 新 增 长 者 < / button > : null }
{ tab === "subjects" && canWriteSubject && selectedSubjectID ? < button type = "button" className = "button-secondary" onClick = { ( ) = > setSubjectOperationsOpen ( true ) } > 资 料 与 授 权 < / button > : null }
@@ -1207,9 +1283,9 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
{ 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 }
{ householdCreateOpen ? < div className = "workspace-dialog-backdrop" role = "presentation" onMouseDown = { ( event ) = > { if ( event . currentTarget === event . target ) setHouseholdCreateOpen ( false ) ; } } > < form className = "workspace-dialog" role = "dialog" aria-modal = "true" aria-labelledby = "create-household-title" onSubmit = { ( event ) = > { void createHousehold ( event ) ; setHouseholdCreateOpen ( false ) ; } } > < header > < div > < h3 id = "create-household-title" > 新 增 家 庭 < / h3 > < p > 创 建 后 当 前 账 号 自 动 成 为 家 庭 所 有 者 。 < / p > < / div > < button type = "button" aria-label = "关闭" onClick = { ( ) = > setHouseholdCreateOpen ( false ) } > < CloseOutlined / > < / button > < / header > < div className = "workspace-dialog__body" > < label > < span > 客 户 组 织 < / span > < select value = { newHouseholdTenantID } onChange = { ( event ) = > setNewHouseholdTenantID ( event . target . value ) } required > { accounts . map ( ( account ) = > < option key = { account . user_id } value = { account . tenant_id } > { account . tenant_name } < / option > ) } < / select > < / label > < label > < span > 家 庭 名 称 < / span > < input autoFocus value = { newHouseholdName } maxLength = { 200 } onChange = { ( event ) = > setNewHouseholdName ( event . target . value ) } placeholder = "例如:温馨之 家" required / > < / label > < / div > < footer > < button type = "button" className = "button-secondary" onClick = { ( ) = > setHouseholdCreateOpen ( false ) } > 取 消 < / button > < button type = "submit" disabled = { actionBusy || ! newHouseholdTenantID || ! newHouseholdName . trim ( ) } > 下 一 步 确 认 < / button > < / footer > < / form > < / div > : null }
{ householdCreateOpen ? < div className = "workspace-dialog-backdrop" role = "presentation" onMouseDown = { ( event ) = > { if ( event . currentTarget === event . target ) setHouseholdCreateOpen ( false ) ; } } > < form className = "workspace-dialog" role = "dialog" aria-modal = "true" aria-labelledby = "create-household-title" onSubmit = { ( event ) = > { void createHousehold ( event ) ; setHouseholdCreateOpen ( false ) ; } } > < header > < div > < h3 id = "create-household-title" > 新 增 家 庭 < / h3 > < p > 创 建 后 当 前 账 号 自 动 成 为 家 庭 所 有 者 。 < / p > < / div > < button type = "button" aria-label = "关闭" onClick = { ( ) = > setHouseholdCreateOpen ( false ) } > < CloseOutlined / > < / button > < / header > < div className = "workspace-dialog__body" > < label > < span > 家 庭 名 称 < / span > < input autoFocus value = { newHouseholdName } maxLength = { 200 } onChange = { ( event ) = > setNewHouseholdName ( event . target . value ) } placeholder = "例如:爸爸妈妈、外婆 家" required / > < / label > < / div > < footer > < button type = "button" className = "button-secondary" onClick = { ( ) = > setHouseholdCreateOpen ( false ) } > 取 消 < / button > < button type = "submit" disabled = { actionBusy || ! newHouseholdTenantID || ! newHouseholdName . trim ( ) } > 下 一 步 确 认 < / button > < / footer > < / form > < / div > : null }
{ state === "loading" ? < StatePanel title = "正在载入家庭数据" detail = "正在校验客户组织和 家庭成员关系…" / > : null }
{ state === "loading" ? < StatePanel title = "正在载入家庭数据" detail = "正在核验 家庭成员关系与可见范围 …" / > : null }
{ state === "error" ? < StatePanel title = "家庭数据载入失败" detail = { error ? ? "未知错误" } action = { ( ) = > void loadRoot ( ) } / > : null }
{ state === "ready" && households . length === 0 ? < StatePanel title = "还没有可访问的家庭" detail = "创建第一个家庭,或前往家庭管理接受已有家庭的邀请。" action = { ( ) = > setHouseholdCreateOpen ( true ) } actionLabel = "新增家庭" / > : null }
{ actionMessage ? < div className = "workspace__action-message" role = "status" > { actionMessage } < / div > : null }
@@ -1218,16 +1294,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
{ state === "ready" && selectedHousehold ? (
< >
{ tab === "home" ? < div className = "workspace__summary" >
< Metric label = "长者档案" value = { subjects . length } detail = { canWriteSubject ? "当前角色可编辑" : "当前角色只读" } / >
< Metric label = "有效分项授权" value = { effectiveConsents } detail = "共五类独立授权" / >
< Metric label = "已确认事实" value = { confirmedFacts } detail = { ` ${ facts . length - confirmedFacts } 项等待核对 ` } / >
< Metric label = "家庭设备" value = { devices . filter ( ( item ) = > item . household_id === selectedHousehold . id ) . length } detail = { ` ${ devices . filter ( ( item ) = > item . household_id === selectedHousehold . id && presence [ item . id ] ? . online ) . length } 台在线 ` } / >
< / div > : null }
{ tab === "home" && selectedSubject ? < ConversationReadinessPanel readiness = { conversationReadiness } subject = { selectedSubject }
loading = { subjectLoading } onAction = { ( action ) = > selectSection ( action === "EDIT_PROFILE" || action === "MANAGE_CONSENTS" ? "subjects" : "devices" ) } / > : null }
{ showNavigation ? < nav className = "workspace__tabs" aria-label = "业务模块" >
< TabButton active = { tab === "home" } onClick = { ( ) = > selectSection ( "home" ) } > 首 页 < / TabButton >
< TabButton active = { tab === "members" } onClick = { ( ) = > selectSection ( "members" ) } > 家 庭 成 员 < / TabButton >
@@ -1243,9 +1309,10 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
< / nav > : null }
{ subjectError ? < div className = "workspace__inline-error" role = "alert" > { subjectError } < / div > : null }
{ tab === "home" ? < CustomerHomePanel household = { selectedHousehold } subject = { selectedSubject } subjects = { subjects }
{ tab === "home" ? < CustomerHomePanel subject = { selectedSubject } subjects = { subjects }
devices = { devices . filter ( ( item ) = > item . household_id === selectedHousehold . id ) } presence = { presence }
conversations = { conversations } stories = { stories } privacyRequests = { privacyRequests }
conversations = { conversations } insights = { conversationInsights } stories = { stories } privacyRequests = { privacyRequests }
readiness = { conversationReadiness } onSelectSubject = { setSelectedSubjectID }
onOpen = { ( section ) = > selectSection ( section ) } / > : null }
{ tab === "members" ? < MemberPanel household = { selectedHousehold } households = { households } members = { members } invitations = { invitations }
canManage = { canManageMembers } canTransfer = { canTransferOwnership } actionBusy = { actionBusy }
@@ -1259,14 +1326,23 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
< form className = "subject-create-modal" role = "dialog" aria-modal = "true" aria-labelledby = "subject-create-title" onSubmit = { ( event ) = > { createSubject ( event ) ; setSubjectCreateOpen ( false ) ; } } >
< header > < div > < h3 id = "subject-create-title" > 新 增 长 者 < / h3 > < p > 先 建 立 基 础 档 案 , 兴 趣 和 交 流 偏 好 可 以 稍 后 继 续 完 善 < / p > < / div > < button type = "button" aria-label = "关闭新增长者弹窗" onClick = { ( ) = > setSubjectCreateOpen ( false ) } > < CloseOutlined / > < / button > < / header >
< div className = "subject-create-modal__body" >
< div className = "subject-avatar-upload" aria-hidden = "true" > < UserAddOutlined / > < span > 长 者 头 像 < / span > < / div >
< label className = { newSubjectAvatar ? "subject-avatar-upload has-image" : "subject-avatar-upload" } >
{ newSubjectAvatar ? < img src = { newSubjectAvatar } alt = "待上传的长者头像预览" / > : < > < UploadOutlined / > < span > 上 传 长 者 头 像 < / span > < / > }
< input type = "file" accept = "image/jpeg,image/png,image/webp" onChange = { ( event ) = > void selectNewSubjectAvatar ( event . target . files ? . [ 0 ] ) } / >
< small > { newSubjectAvatar ? "点击更换" : "JPG、PNG 或 WebP" } < / small >
< / label >
{ newSubjectAvatarError ? < p className = "subject-avatar-error" role = "alert" > { newSubjectAvatarError } < / p > : null }
< label className = "subject-field subject-field--wide" > < span > 称 呼 < / span > < input value = { newSubjectName } maxLength = { 120 } onChange = { ( event ) = > setNewSubjectName ( event . target . value ) } placeholder = "请输入长者的称呼,如:爷爷、张奶奶" required / > < / label >
< div className = "dialogue-presets subject-field--wide" > < div > < strong > 陪 伴 方 式 模 板 < / strong > < small > 模 板 只 填 充 建 议 值 , 保 存 前 仍 可 逐 项 修 改 < / small > < / div > < div > { dialoguePresets . map ( ( preset ) = > < button type = "button" key = { preset . key } onClick = { ( ) = > applyNewSubjectDialoguePreset ( preset ) } > < strong > { preset . label } < / strong > < span > { preset . description } < / span > < / button > ) } < / div > < / div >
< div className = "subject-create-modal__row" >
< label className = "subject-field" > < span > 语 言 < / span > < select value = "zh-CN" disabled > < option value = "zh-CN" > 普 通 话 < / option > < / select > < / label >
< label className = "subject-field" > < span > 交 流 节 奏 < / span > < select value = { newSubjectPace } onChange = { ( event ) = > setNewSubjectPace ( event . target . value as ConversationPace ) } > < option value = "slow" > 较 慢 , 需 要 更 多 耐 心 < / option > < option value = "normal" > 自 然 日 常 节 奏 < / option > < option value = "lively" > 轻 快 交 流 < / option > < / select > < / label >
< label className = "subject-field" > < span > 交 流 节 奏 < / span > < select value = { newSubjectPace } onChange = { ( event ) = > setNewSubjectPace ( event . target . value as ConversationPace ) } > < option value = "slow" > 较 慢 , 需 要 更 多 耐 心 < / option > < option value = "normal" > 自 然 日 常 节 奏 < / option > < option value = "lively" > 轻 快 交 流 < / option > < / select > < small > 会 在 每 次 新 聊 天 开 始 时 传 给 豆 包 , 控 制 语 速 和 留 白 。 < / small > < / label >
< / div >
< label className = "subject-field subject-field--wide" > < span > 背 景 资 料 < / span > < textarea value = { newSubjectBackground } maxLength = { 500 } onChange = { ( event ) = > setNewSubjectBackground ( event . target . value ) } placeholder = "记录长者的兴趣爱好、过往经历或需要特别注意的事项" required / > < / label >
< label className = "subject-field subject-field--wide" > < span > 本 次 交 流 目 标 < / span > < input value = { newSubjectGoal } maxLength = { 200 } onChange = { ( event ) = > setNewSubjectGoal ( event . target . value ) } placeholder = "例如:聊聊第一次参加工作的经历" required / > < / label >
< label className = "subject-field subject-field--wide" > < span > 背 景 资 料 < / span > < textarea value = { newSubjectBackground } maxLength = { 500 } onChange = { ( event ) = > setNewSubjectBackground ( event . target . value ) } placeholder = "例如:退休前是教师,年轻时在天津生活过" required / > < small > 只 填 写 已 经 核 实 、 允 许 用 于 陪 伴 交 流 的 事 实 , 不 要 填 写 账 号 、 证 件 或 联 系 方 式 。 < / small > < / label >
< label className = "subject-field subject-field--wide" > < span > 兴 趣 标 签 < / span > < input value = { newSubjectInterests } maxLength = { 400 } onChange = { ( event ) = > setNewSubjectInterests ( event . target . value ) } placeholder = "例如:京剧、养花、做饭(用逗号分隔,最多8项)" / > < small > 用 于 寻 找 低 压 力 的 开 场 话 题 。 < / small > < / label >
< label className = "subject-field subject-field--wide" > < span > 长 期 陪 伴 方 向 < / span > < input value = { newSubjectGoal } maxLength = { 200 } onChange = { ( event ) = > setNewSubjectGoal ( event . target . value ) } placeholder = "例如:多聊年轻时的工作经历,让长者心情放松" required / > < small > 这 是 长 期 偏 好 , 会 从 后 续 每 次 新 聊 天 开 始 生 效 ; 不 会 要 求 长 者 必 须 回 答 。 < / small > < / label >
< label className = "subject-field subject-field--wide" > < span > 优 先 话 题 < / span > < input value = { newSubjectTopics } maxLength = { 400 } onChange = { ( event ) = > setNewSubjectTopics ( event . target . value ) } placeholder = "例如:家乡、京剧、孙辈(用逗号分隔,最多8项)" / > < small > 智 能 体 会 优 先 从 这 些 话 题 中 选 择 自 然 的 切 入 点 。 < / small > < / label >
< label className = "subject-field subject-field--wide" > < span > 避 免 主 动 提 及 < / span > < input value = { newSubjectAvoidedTopics } maxLength = { 400 } onChange = { ( event ) = > setNewSubjectAvoidedTopics ( event . target . value ) } placeholder = "例如:收入、家庭矛盾(用逗号分隔,最多8项)" / > < small > 智 能 体 不 会 主 动 提 起 ; 长 者 主 动 提 及 时 会 先 共 情 , 再 谨 慎 回 应 。 < / small > < / label >
< / div >
< footer > < button type = "button" className = "subject-modal-cancel" onClick = { ( ) = > setSubjectCreateOpen ( false ) } > 取 消 < / button > < button type = "submit" disabled = { actionBusy || ! newSubjectName . trim ( ) || ! newSubjectBackground . trim ( ) || ! newSubjectGoal . trim ( ) } > 立 即 创 建 < / button > < / footer >
< / form >
@@ -1321,7 +1397,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
/ > : n u l l }
{ tab === "conversations" ? (
< ConversationPanel subjects = { subjects } selectedID = { selectedSubjectID } onSelect = { setSelectedSubjectID }
conversations = { conversations } detail = { detail } usage = { conversationUsage } turns = { turns } summaries = { contentSummaries }
conversations = { conversations } insights = { conversationInsights } detail = { detail } usage = { conversationUsage } turns = { turns } summaries = { contentSummaries }
media = { conversationMedia } mediaState = { mediaState } mediaError = { mediaError } grants = { recordingGrants } members = { members }
canManageRecordingAccess = { canManageRecordingAccess } actionBusy = { actionBusy } audioURLs = { audioURLs }
loading = { conversationLoading } detailLoading = { detailLoading } loadingMore = { conversationLoadingMore } error = { conversationError }
@@ -1354,37 +1430,75 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
) ;
}
function CustomerHomePanel ( { household , subject , subjects , devices , presence , conversations , stories , privacyRequests , onOpen } : {
household : Household ;
function CustomerHomePanel ( { subject , subjects , devices , presence , conversations , insights , stories , privacyRequests , readiness , onSelectSubject , onOpen } : {
subject? : Subject ;
subjects : Subject [ ] ;
devices : ManagedDevice [ ] ;
presence : Record < string , DevicePresence > ;
conversations : ConversationSummary [ ] ;
insights : Record < string , ConversationInsight > ;
stories : Story [ ] ;
privacyRequests : PrivacyRequest [ ] ;
readiness? : ConversationReadiness ;
onSelectSubject : ( id : string ) = > void ;
onOpen : ( section : CustomerSection ) = > void ;
} ) {
const onlineDevices = devices . filter ( ( item ) = > presence [ item . id ] ? . online ) . length ;
const activeConversations = conversations . filter ( ( item ) = > [ "PREPARING" , "ROOM_ISSUED" , "STARTING_AGENT" , "ACTIVE" , "STOPPING" ] . includes ( item . status ) ) . length ;
const pendingStories = stories . filter ( ( item ) = > item . status === "CANDIDATE" || item . status === "IN_REVIEW" ) . length ;
return < div className = "customer-home" >
< section className = "customer-welcome-card" >
< div > < p className = "eyebrow" > WELCOME HOME < / p > < h3 > { subject ? ` 守护 ${ subject . preferred_name } 的每一次陪伴 ` : ` 欢迎进入 ${ household . name } ` } < / h3 > < p > 设 备 状 态 、 通 话 进 展 、 授 权 和 家 庭 故 事 都 在 同 一 处 更 新 , 所 有 内 容 只 对 有 权 限 的 家 庭 成 员 开 放 。 < / p > < / div >
< button type = "button" onClick = { ( ) = > onOpen ( subject ? "conversations" : "subjects" ) } > { subject ? "查看最近通话" : "添加长者档案" } < / button >
< / section >
< section className = "customer-quick-grid" aria-label = "家庭概况" >
< button type = "button" onClick = { ( ) = > onOpen ( "subjects" ) } > < span > 长 者 档 案 < / span > < strong > { subjects . length } < / strong > < small > { subject ? . preferred_name ? ? "等待添加档案" } < / small > < / button >
< button type = "button" onClick = { ( ) = > onOpen ( "devices" ) } > < span > 在 线 设 备 < / span > < strong > { onlineDevices } / { devices . length } < / strong > < small > { devices . length ? "查看设备与音量配置" : "等待绑定设备" } < / small > < / button >
< button type = "button " onClick = { ( ) = > onOpen ( "conversations" ) } > < span > 进 行 中 通 话 < / span > < strong > { activeConversations } < / strong > < small > 当 前 长 者 已 加 载 { conversations . length } 条 记 录 < / small > < / button >
< button type = "button" onClick = { ( ) = > onOpen ( "stories" ) } > < span > 待 确 认 故 事 < / span > < strong > { pendingStories } < / strong > < small > { stories . length } 条 故 事 候 选 与 回 忆 < / small > < / button >
< / section >
< section className = "customer-action-list" >
< div > < p className = "eyebrow" > QUICK ACTIONS < / p > < h3 > 常 用 入 口 < / h3 > < / div >
< button type = "button" onClick = { ( ) = > onOpen ( "members" ) } > < strong > 邀 请 家 人 < / strong > < span > 添 加 成 员 并 分 配 查 看 或 编 辑 权 限 < / span > < / button >
< button type = "button " onClick = { ( ) = > onOpen ( "sharing " ) } > < strong > 分 享 故 事 < / strong > < span > 创 建 定 向 分 享 并 随 时 撤 销 < / span > < / button >
< button type = "button" onClick = { ( ) = > onOpen ( "privacy" ) } > < strong > 隐 私 申 请 < / strong > < span > { privacyRequests . length } 条 访 问 、 导 出 或 删 除 申 请 < / span > < / button >
< / section >
const completed = conversations . filter ( ( item ) = > item . status === "COMPLETED" ) ;
const latest = completed [ 0 ] ;
const latestInsight = latest ? insights [ latest . id ] : undefined ;
const week = conversationWeek ( completed , insights ) ;
const totalMinutes = Math . round ( week . reduce ( ( sum , item ) = > sum + item . durationMS , 0 ) / 60000 ) ;
const attention = week . filter ( ( item ) = > item . insight . mood === "attention" ) ;
const activePrivacy = privacyRequests . filter ( ( item ) = > ! [ "COMPLETED" , "REJECTED" , "FAILED" ] . includes ( item . status ) ) ;
const pendingStories = stories . filter ( ( item ) = > item . status === "CANDIDATE" || item . status === "IN_REVIEW" ) ;
const selectedDevice = subject ? devices . find ( ( item ) = > item . subject_id === subject . id ) : undefined ;
const isOnline = selectedDevice ? presence [ selectedDevice . id ] ? . online : false ;
return < div className = "conversation-journal" >
< nav className = "elder-switcher " role = "tablist" aria-label = "切换长者" >
{ subjects . map ( ( item , index ) = > < button type = "button" role = "tab" aria-selected = { item . id === subject ? . id } className = { item . id === subject ? . id ? "active" : "" } key = { item . id } onClick = { ( ) = > onSelectSubject ( item . id ) } >
< SubjectAvatar subject = { item } index = { index } / >
< strong > { item . preferred_name } < / strong >
< small > { profileString ( item . profile_data , "relation" ) || "长者" } < / small >
< / button > ) }
{ subjects . length === 0 ? < button type = "button" className = "elder-switcher__empty " onClick = { ( ) = > onOpen ( "subjects " ) } > < UserAddOutlined / > < strong > 添 加 第 一 位 长 者 < / strong > < / button > : null }
< / nav >
{ subject ? < >
< div className = "journal-grid" >
< section className = "journal-card journal-weekly" >
< header > < div > < h3 > 陪 伴 周 记 < / h3 > < p > 最 近 7 天 聊 了 { week . filter ( ( item ) = > item . durationMS > 0 ) . length } 次 , 共 { totalMinutes } 分 钟 < / p > < / div > < span className = { isOnline ? "elder-presence is-online" : "elder-presence" } > { isOnline ? "设备在线" : selectedDevice ? "设备离线" : "未绑定设备" } < / span > < / header >
< div className = "journal-weekly__chart" aria-label = "最近七天聊天时长和心情线索" >
{ week . map ( ( day ) = > < article className = { day . isToday ? "is-today" : "" } key = { day . key } >
< div > < time > { day . dateLabel } < / time > < small > { day . weekday } < / small > < / div >
< strong > { day . durationMS ? ` ${ Math . max ( 1 , Math . round ( day . durationMS / 60000 ) ) } 分钟 ` : "0分钟" } < / strong >
< span className = "journal-duration" style = { { height : ` ${ Math . max ( 3 , Math . min ( 88 , day . durationMS / Math . max ( 1 , weekMaxDuration ( week ) ) * 88 ) ) } px ` } } / >
< span className = { ` journal-mood journal-mood-- ${ day . insight . mood } ` } > < SmileOutlined / > < / span >
< small > { day . insight . moodLabel } < / small >
< / article > ) }
< / div >
< footer > < span > < i className = "journal-legend-duration" / > 聊 天 时 长 < / span > < span > 心 情 线 索 来 自 已 授 权 聊 天 摘 要 < / span > < / footer >
< / section >
< section className = "journal-card latest-chat" >
< header > < div > < h3 > 最 近 一 次 聊 天 < / h3 > < p > { latest ? ` ${ formatDate ( latest . created_at ) } · ${ formatDuration ( latest . duration_ms ) } ` : "还没有完成的聊天" } < / p > < / div > < / header >
{ latest ? < >
< div className = "latest-chat__topics" > { ( latestInsight ? . topics . length ? latestInsight . topics : [ "日常陪伴" ] ) . map ( ( topic ) = > < span key = { topic } > { topic } < / span > ) } < / div >
< blockquote > { latestInsight ? . summary || "本次聊天已结束,摘要仍在整理中。" } < / blockquote >
< div className = { ` latest-chat__mood latest-chat__mood-- ${ latestInsight ? . mood ? ? "steady" } ` } > < SmileOutlined / > < div > < strong > 心 情 线 索 : { latestInsight ? . moodLabel ? ? "平稳" } < / strong > < small > { latestInsight ? . evidence ? ? "暂未发现明显情绪变化" } < br / > 仅 根 据 已 授 权 的 聊 天 内 容 推 测 , 不 是 健 康 诊 断 < / small > < / div > < / div >
< button type = "button" onClick = { ( ) = > onOpen ( "conversations" ) } > 查 看 完 整 聊 天 < / button >
< / > : < div className = "journal-empty" > < MessageOutlined / > < p > 完 成 第 一 次 聊 天 后 , 这 里 会 展 示 摘 要 、 话 题 和 心 情 线 索 。 < / p > < button type = "button" onClick = { ( ) = > onOpen ( "subjects" ) } > 检 查 陪 伴 准 备 < / button > < / div > }
< / section >
< / div >
< div className = "journal-lower-grid" >
< section className = "journal-card family-attention" > < header > < HeartOutlined / > < h3 > 值 得 家 人 关 心 < / h3 > < / header >
{ attention . length ? < button type = "button" onClick = { ( ) = > onOpen ( "conversations" ) } > < strong > 最 近 聊 天 里 出 现 了 需 要 留 意 的 表 达 < / strong > < span > { attention [ 0 ] . insight . evidence } 。 建 议 先 温 和 问 候 , 不 要 把 推 测 当 作 结 论 。 < / span > < / button > : readiness && ! readiness . ready ? < button type = "button" onClick = { ( ) = > onOpen ( "subjects" ) } > < strong > 陪 伴 准 备 还 有 { readiness . checks . filter ( ( item ) = > item . blocking && item . status !== "ready" ) . length } 项 未 完 成 < / strong > < span > 补 全 档 案 、 授 权 或 设 备 后 , 豆 包 才 能 按 设 定 开 始 聊 天 。 < / span > < / button > : < div className = "family-attention__calm" > < SmileOutlined / > < span > 最 近 没 有 发 现 需 要 特 别 提 醒 的 情 绪 线 索 , 保 持 自 然 联 系 就 很 好 。 < / span > < / div > }
< / section >
< section className = "journal-card recent-activity" > < header > < h3 > 最 近 动 态 < / h3 > < / header > < div >
{ latest ? < button type = "button" onClick = { ( ) = > onOpen ( "conversations" ) } > < MessageOutlined / > < time > { formatDate ( latest . created_at ) } < / time > < strong > 与 { subject . preferred_name } 聊 天 { formatDuration ( latest . duration_ms ) } < / strong > < span > { latestInsight ? . topics . join ( "、" ) || "聊天已完成" } < / span > < / button > : null }
{ stories [ 0 ] ? < button type = "button" onClick = { ( ) = > onOpen ( "stories" ) } > < BookOutlined / > < time > { formatDate ( stories [ 0 ] . created_at ) } < / time > < strong > { pendingStories . length ? "有新记忆等待确认" : "最近保存了一段记忆" } < / strong > < span > { stories [ 0 ] . title } < / span > < / button > : null }
{ activePrivacy . length ? < button type = "button" onClick = { ( ) = > onOpen ( "privacy" ) } > < SafetyCertificateOutlined / > < time > 处 理 中 < / time > < strong > 有 { activePrivacy . length } 项 隐 私 请 求 < / strong > < span > 查 看 进 度 与 处 理 结 果 < / span > < / button > : null }
{ ! latest && ! stories . length && ! activePrivacy . length ? < p className = "empty-copy" > 还 没 有 新 的 家 庭 动 态 。 < / p > : null }
< / div > < footer > < button type = "button" onClick = { ( ) = > onOpen ( "messages" ) } > 查 看 全 部 动 态 < / button > < / footer > < / section >
< / div >
< / > : null }
< / div > ;
}
@@ -1412,22 +1526,22 @@ function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequ
}
function customerSectionTitle ( section : Tab ) {
return ( { home : "家庭首页 " , members : "家庭管理 " , subjects : "长者管理 " , devices : "设备管理 " , firmware : "固件发布" , conversations : "通话 记录" , stories : "故事与记忆" , sharing : "资源 分享" , privacy : "隐私请求 " , billing : "用量与费用 " , messages : "消息中心 " } as Record < Tab , string > ) [ section ] ;
return ( { home : "陪伴周记 " , members : "家人共享 " , subjects : "长者资料 " , devices : "陪伴 设备" , firmware : "固件发布" , conversations : "聊天 记录" , stories : "故事与记忆" , sharing : "内容 分享" , privacy : "隐私与授权 " , billing : "服务 用量" , messages : "日程与提醒 " } as Record < Tab , string > ) [ section ] ;
}
function customerSectionDescription ( section : Tab ) {
return ( {
home : "查看家庭陪伴服务的最新状态和待办事项 。" ,
members : "管理家庭空间、成员 邀请、角色和所有权。" ,
subjects : "维护长者档案 、稳定事实和分项授权。" ,
home : "查看长者最近聊天、心情线索和值得家人关心的变化 。" ,
members : "管理家庭空间、家人 邀请、角色和所有权。" ,
subjects : "维护长者长期档案、交流节奏 、稳定事实和分项授权。" ,
devices : "绑定陪伴设备,查看在线状态并调整安全配置。" ,
firmware : "管理设备固件发布。" ,
conversations : "查看通话进展 、文字内容、 录音授权和单次费用 。" ,
conversations : "回看聊天摘要、话题、心情线索 、文字内容和 录音授权。" ,
stories : "核对从通话中整理的故事和长期记忆。" ,
sharing : "按资源和权限范围向指定家庭成员分享内容。" ,
privacy : "提交访问、导出或删除申请并跟踪处理进度。" ,
billing : "汇总本家庭及设备的通话时长,并在核价可用时展示费用。" ,
messages : "集中查看设备、通话 、故事和隐私请求的状态提醒。"
messages : "集中查看陪伴动态、 设备、故事和隐私请求的状态提醒。"
} as Record < Tab , string > ) [ section ] ;
}
@@ -1439,32 +1553,7 @@ function resourceTypeLabel(type: string) {
return ( { device : "设备" , conversation : "通话" , story : "故事" , upload_batch : "媒体" , export_task : "导出任务" , privacy_request : "隐私请求" , device_command : "设备指令" } as Record < string , string > ) [ type ] ? ? "业务资源" ;
}
function ConversationReadinessPanel ( { readiness , subject , loading , onAction } : {
readiness? : ConversationReadiness ; subject : Subject ; loading : boolean ; onAction : ( action : string ) = > void ;
} ) {
if ( loading && ! readiness ) {
return < section className = "conversation-readiness conversation-readiness--loading" aria-label = "首次通话就绪检查" > < span > 正 在 核 对 首 次 通 话 条 件 … < / span > < / section > ;
}
if ( ! readiness ) return null ;
const blocked = readiness . checks . filter ( ( item ) = > item . blocking && item . status !== "ready" ) . length ;
const attention = readiness . checks . filter ( ( item ) = > item . status === "attention" ) . length ;
return < section className = { ` conversation-readiness ${ readiness . ready ? "conversation-readiness--ready" : "conversation-readiness--blocked" } ` } aria-label = "首次通话就绪检查" >
< div className = "conversation-readiness__head" >
< div > < p className = "eyebrow" > FIRST CALL READINESS < / p > < h3 > { subject . preferred_name } 的 首 次 通 话 准 备 < / h3 > < p > { readiness . ready ? "家庭可处理的准备项均已完成。" : ` 还有 ${ blocked } 项需要处理。 ` } { attention ? ` 另有 ${ attention } 项运行提醒。 ` : "" } < / p > < / div >
< span className = { readiness . ready ? "readiness-state readiness-state--ready" : "readiness-state readiness-state--blocked" } > { readiness . ready ? "基础就绪" : "尚未就绪" } < / span >
< / div >
< div className = "conversation-readiness__checks" >
{ readiness . checks . map ( ( check ) = > < article key = { check . code } className = { ` readiness-check readiness-check-- ${ check . status } ` } >
< span className = "readiness-check__icon" aria-hidden = "true" > { check . status === "ready" ? "✓" : check . status === "blocked" ? "!" : "i" } < / span >
< div > < strong > { check . title } < / strong > < p > { check . detail } < / p > < / div >
{ check . action ? < button type = "button" className = "button-small button-secondary" onClick = { ( ) = > onAction ( check . action ! ) } > { readinessActionLabel ( check . action ) } < / button > : null }
< / article > ) }
< / div >
< p className = "conversation-readiness__foot" > 创 建 通 话 时 还 会 实 时 校 验 用 量 额 度 、 运 行 路 由 和 是 否 已 有 进 行 中 通 话 , 因 此 本 清 单 不 承 诺 瞬 时 资 源 一 定 可 用 。 检 查 时 间 : { formatDate ( readiness . evaluated_at ) } < / p >
< / section > ;
}
function MemberPanel ( { household , households , members , invitations , canManage , canTransfer , actionBusy , inviteeHint , onInviteeHint , inviteRole , onInviteRole , issuedToken , onDismissToken , onCreateInvitation , onUpdateRole , onRemove , onRevoke , onTransfer , onSelectHousehold , onAddHousehold , onAcceptInvitation , onRenameHousehold } : {
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 ;
issuedToken? : string ; onDismissToken : ( ) = > void ; onCreateInvitation : ( event : FormEvent ) = > void ;
@@ -1484,14 +1573,9 @@ function MemberPanel({ household, households, members, invitations, canManage, c
return < div className = "family-overview" >
< header className = "family-overview__head" >
< div className = "family-overview__identity" >
< small > 当 前 家 庭 < / small >
< small > 家 庭 空 间 < / small >
< div className = "family-overview__title-row" >
< label className = "family-switcher" aria-label = "切换当前家庭" >
< select value = { household . id } onChange = { ( event ) = > onSelectHousehold ( event . target . value ) } >
{ households . map ( ( item ) = > < option key = { item . id } value = { item . id } > { item . name } < / option > ) }
< / select >
< DownOutlined / >
< / label >
< h2 > { household . name } < / h2 >
< button type = "button" className = "family-icon-button family-icon-button--edit" aria-label = "编辑家庭名称" disabled = { ! canManage } onClick = { ( ) = > setEditOpen ( true ) } > < EditOutlined / > < / button >
< button type = "button" className = "family-icon-button family-icon-button--delete" aria-label = "删除家庭" title = "当前业务接口不支持删除家庭" disabled > < DeleteOutlined / > < / button >
< button type = "button" className = "family-add-button" onClick = { onAddHousehold } > < PlusOutlined / > 添 加 家 庭 < / button >
@@ -1536,7 +1620,7 @@ function SubjectPanel({ subjects, selectedID, onSelect, subject, facts, consents
const interests = profileList ( item . profile_data , "interests" ) ;
const pace = conversationPaceLabel ( profilePace ( item . conversation_preferences ) ) ;
return < button type = "button" className = { item . id === selectedID ? "subject-profile-card active" : "subject-profile-card" } key = { item . id } onClick = { ( ) = > openSubject ( item . id ) } >
< span className = { ` subject-profile-card__avatar subject-profile-card__avatar-- ${ index % 3 } ` } > < UserOutlined / > < / span >
< span className = { ` subject-profile-card__avatar subject-profile-card__avatar-- ${ index % 3 } ` } > < SubjectAvatar subject = { item } index = { index } / > < / span >
< span className = "subject-profile-card__identity" > < strong > { item . preferred_name } < / strong > < small > { item . timezone } · 档 案 版 本 v { item . version } < / small > < / span >
< span className = { ` subject-profile-card__status subject-profile-card__status-- ${ item . status } ` } > { statusLabel ( item . status ) } < / span >
< span className = "subject-profile-card__facts" > < span > < small > 语 言 < / small > < strong > { item . language } < / strong > < / span > < span > < small > 交 流 节 奏 < / small > < strong > { pace } < / strong > < / span > < / span >
@@ -1551,7 +1635,7 @@ function SubjectPanel({ subjects, selectedID, onSelect, subject, facts, consents
{ loading ? < StatePanel title = "正在读取长者资料" detail = "同步档案、事实和授权…" / > : subject ? < >
< header className = "subject-detail-modal__header" > < div > < h3 id = "subject-detail-title" > 完 善 { subject . preferred_name } 的 资 料 < / h3 > < p > 帮 助 智 能 体 更 好 地 了 解 家 人 , 提 供 更 贴 心 的 陪 伴 < / p > < / div > < div className = "subject-profile-completion" > < span > < strong > { subjectProfileCompletion ( subject ) } % < / strong > < / span > < small > 资 料 完 善 度 < / small > < / div > < button type = "button" aria-label = "关闭资料详情" onClick = { ( ) = > setDetailOpen ( false ) } > < CloseOutlined / > < / button > < / header >
< div className = "subject-detail-modal__body" >
< div className = "subject-detail-summary" > < span className = "subject-profile-card__avatar" > < UserOutlined / > < / span > < div > < strong > { subject . preferred_name } < / strong > < small > { subject . timezone } · 档 案 版 本 v { subject . version } < / small > < / div > < span className = { canWrite ? "permission permission--write" : "permission" } > { canWrite ? "可编辑" : "只读" } < / span > < / div >
< div className = "subject-detail-summary" > < span className = "subject-profile-card__avatar" > < SubjectAvatar subject = { subject } index = { subjects . findIndex ( ( item ) = > item . id === subject . id ) } / > < / span > < div > < strong > { subject . preferred_name } < / strong > < small > { subject . timezone } · 档 案 版 本 v { subject . version } < / small > < / div > < span className = { canWrite ? "permission permission--write" : "permission" } > { canWrite ? "可编辑" : "只读" } < / span > < / div >
< SubjectProfileEditor subject = { subject } canWrite = { canWrite } actionBusy = { actionBusy } onSave = { onUpdateSubject } / >
< div className = "subject-record-grid" >
< section className = "subject-record-card" > < div className = "subject-section-heading" > < span className = "subject-section-icon subject-section-icon--clock" > < ClockCircleOutlined / > < / span > < div > < h4 > 稳 定 事 实 < / h4 > < p > 经 过 家 属 核 对 的 生 活 背 景 与 重 要 事 项 < / p > < / div > < strong > { facts . length } < / strong > < / div > { facts . length ? facts . map ( ( fact ) = > < article className = "subject-data-row" key = { fact . id } > < div > < strong > { factTypeLabel ( fact . fact_type ) } < / strong > < p > { fact . content } < / p > < / div > < div className = "row-actions" > < span className = { ` tag tag-- ${ fact . confirmation_status } ` } > { factStatusLabel ( fact . confirmation_status ) } < / span > { canWrite && fact . confirmation_status === "pending" ? < button type = "button" className = "button-small" disabled = { actionBusy } onClick = { ( ) = > void onConfirmFact ( fact ) } > 确 认 < / button > : null } < / div > < / article > ) : < p className = "empty-copy" > 暂 无 稳 定 事 实 或 候 选 。 < / p > } < / section >
@@ -1569,15 +1653,19 @@ function SubjectProfileEditor({ subject, canWrite, actionBusy, onSave }: {
onSave : ( subject : Subject , input : UpdateSubjectRequest ) = > void ;
} ) {
const [ name , setName ] = useState ( subject . preferred_name ) ;
const [ avatar , setAvatar ] = useState ( profileString ( subject . profile_data , "avatar_data_url" ) ) ;
const [ avatarError , setAvatarError ] = useState ( "" ) ;
const [ background , setBackground ] = useState ( profileString ( subject . profile_data , "background" ) ) ;
const [ interests , setInterests ] = useState ( profileList ( subject . profile_data , "interests" ) ) ;
const [ goal , setGoal ] = useState ( profileString ( subject . conversation_preferences , "conversation_goal" ) ) ;
const [ pace , setPace ] = useState < ConversationPace > ( profilePace ( subject . conversation_preferences ) ) ;
const [ topics , setTopics ] = useState ( profileList ( subject . conversation_preferences , "preferred_topics" ) ) ;
const [ avoidedTopics , setAvoidedTopics ] = useState ( profileList ( subject . conversation_preferences , "avoided_topics" ) ) ;
const profileChanged = name . trim ( ) !== subject . preferred_name || background . trim ( ) !== profileString ( subject . profile_data , "background" ) || goal . trim ( ) !== profileString ( subject . conversation_preferences , "conversation_goal" ) || pace !== profilePace ( subject . conversation_preferences ) || JSON . stringify ( interests ) !== JSON . stringify ( profileList ( subject . profile_data , "interests" ) ) || JSON . stringify ( topics ) !== JSON . stringify ( profileList ( subject . conversation_preferences , "preferred_topics" ) ) || JSON . stringify ( avoidedTopics ) !== JSON . stringify ( profileList ( subject . conversation_preferences , "avoided_topics" ) ) ;
const profileChanged = name . trim ( ) !== subject . preferred_name || avatar !== profileString ( subject . profile_data , "avatar_data_url" ) || background . trim ( ) !== profileString ( subject . profile_data , "background" ) || goal . trim ( ) !== profileString ( subject . conversation_preferences , "conversation_goal" ) || pace !== profilePace ( subject . conversation_preferences ) || JSON . stringify ( interests ) !== JSON . stringify ( profileList ( subject . profile_data , "interests" ) ) || JSON . stringify ( topics ) !== JSON . stringify ( profileList ( subject . conversation_preferences , "preferred_topics" ) ) || JSON . stringify ( avoidedTopics ) !== JSON . stringify ( profileList ( subject . conversation_preferences , "avoided_topics" ) ) ;
useEffect ( ( ) = > {
setName ( subject . preferred_name ) ;
setAvatar ( profileString ( subject . profile_data , "avatar_data_url" ) ) ;
setAvatarError ( "" ) ;
setBackground ( profileString ( subject . profile_data , "background" ) ) ;
setInterests ( profileList ( subject . profile_data , "interests" ) ) ;
setGoal ( profileString ( subject . conversation_preferences , "conversation_goal" ) ) ;
@@ -1590,11 +1678,32 @@ function SubjectProfileEditor({ subject, canWrite, actionBusy, onSave }: {
if ( ! canWrite || ! profileChanged || ! name . trim ( ) || ! background . trim ( ) || ! goal . trim ( ) ) return ;
onSave ( subject , {
preferred_name : name.trim ( ) ,
profile_data : { . . . subject . profile_data , background : background.trim ( ) , interests } ,
profile_data : { . . . subject . profile_data , background : background.trim ( ) , interests , . . . ( avatar ? { avatar_data_url : avatar } : { avatar_data_url : "" } ) } ,
conversation_preferences : { . . . subject . conversation_preferences , conversation_goal : goal.trim ( ) , pace , preferred_topics : topics , avoided_topics : avoidedTopics }
} ) ;
}
function applyDialoguePreset ( preset : DialoguePreset ) {
if ( ! canWrite ) return ;
setGoal ( preset . goal ) ;
setPace ( preset . pace ) ;
setTopics ( [ . . . preset . preferredTopics ] ) ;
setAvoidedTopics ( [ . . . preset . avoidedTopics ] ) ;
}
async function changeAvatar ( file? : File ) {
if ( ! file || ! canWrite ) return ;
setAvatarError ( "" ) ;
try { setAvatar ( await prepareAvatarDataURL ( file ) ) ; } catch ( cause ) { setAvatarError ( errorMessage ( cause ) ) ; }
}
return < form className = "profile-editor subject-profile-editor" onSubmit = { ( event ) = > void submit ( event ) } >
< section className = "subject-editor-card subject-editor-card--avatar" >
< div className = "subject-section-heading" > < span className = "subject-section-icon subject-section-icon--interest" > < UserOutlined / > < / span > < div > < h4 > 长 者 头 像 < / h4 > < p > 用 于 家 庭 成 员 快 速 识 别 , 只 在 当 前 家 庭 可 见 < / p > < / div > < / div >
< label className = "subject-avatar-editor" > { avatar ? < img src = { avatar } alt = { ` ${ subject . preferred_name } 头像 ` } / > : < SubjectAvatar subject = { subject } index = { 0 } / > } < span > < UploadOutlined / > { avatar ? "更换头像" : "上传头像" } < / span > < input type = "file" disabled = { ! canWrite } accept = "image/jpeg,image/png,image/webp" onChange = { ( event ) = > void changeAvatar ( event . target . files ? . [ 0 ] ) } / > < / label >
{ avatarError ? < p className = "subject-avatar-error" role = "alert" > { avatarError } < / p > : null }
< / section >
< section className = "subject-editor-card subject-editor-card--presets" >
< div className = "subject-section-heading" > < span className = "subject-section-icon subject-section-icon--topic" > < MessageOutlined / > < / span > < div > < h4 > 陪 伴 方 式 模 板 < / h4 > < p > 快 速 设 置 长 期 陪 伴 方 向 、 节 奏 和 话 题 边 界 , 应 用 后 仍 需 核 对 并 保 存 < / p > < / div > < / div >
< div className = "dialogue-presets dialogue-presets--editor" > { dialoguePresets . map ( ( preset ) = > < button type = "button" key = { preset . key } disabled = { ! canWrite } onClick = { ( ) = > applyDialoguePreset ( preset ) } > < strong > { preset . label } < / strong > < span > { preset . description } < / span > < / button > ) } < / div >
< / section >
< section className = "subject-editor-card" >
< div className = "subject-section-heading" > < span className = "subject-section-icon subject-section-icon--interest" > < HeartOutlined / > < / span > < div > < h4 > 兴 趣 标 签 < / h4 > < p > 记 录 长 者 日 常 喜 欢 的 活 动 或 事 物 < / p > < / div > < / div >
< SubjectTagEditor items = { interests } onChange = { setInterests } disabled = { ! canWrite } placeholder = "添加新标签(按回车确认)" emptyText = "尚未添加兴趣标签" removeLabel = "删除兴趣标签" / >
@@ -1602,6 +1711,7 @@ function SubjectProfileEditor({ subject, canWrite, actionBusy, onSave }: {
< section className = "subject-editor-card subject-editor-card--topics" >
< div className = "subject-section-heading" > < span className = "subject-section-icon subject-section-icon--topic" > < MessageOutlined / > < / span > < div > < h4 > 推 荐 话 题 < / h4 > < p > 沟 通 时 容 易 引 起 共 鸣 的 积 极 话 题 < / p > < / div > < / div >
< SubjectTagEditor items = { topics } onChange = { setTopics } disabled = { ! canWrite } placeholder = "添加推荐话题(按回车确认)" emptyText = "尚未添加推荐话题" removeLabel = "删除推荐话题" tone = "topic" / >
< small className = "subject-tag-editor__note" > 优 先 填 写 家 乡 、 兴 趣 、 工 作 经 历 等 容 易 自 然 展 开 的 话 题 。 < / small >
< / section >
< section className = "subject-editor-card subject-editor-card--avoid" >
< div className = "subject-section-heading" > < span className = "subject-section-icon subject-section-icon--avoid" > < StopOutlined / > < / span > < div > < h4 > 避 讳 话 题 < / h4 > < p > 避 免 引 起 长 者 不 悦 或 情 绪 波 动 , 不 影 响 紧 急 安 全 响 应 < / p > < / div > < / div >
@@ -1609,12 +1719,12 @@ function SubjectProfileEditor({ subject, canWrite, actionBusy, onSave }: {
< small className = "subject-tag-editor__note" > 下 一 次 新 通 话 生 效 ; 长 者 主 动 提 及 时 仅 做 温 和 转 向 。 < / small >
< / section >
< section className = "subject-editor-card subject-editor-card--basics" >
< div className = "subject-section-heading" > < span className = "subject-section-icon subject-section-icon--clock" > < ClockCircleOutlined / > < / span > < div > < h4 > 基 本 资 料 与 交 流 目 标 < / h4 > < p > 白 名 单 资 料 只 供 后 续 新 通 话 使 用 , 历 史 通 话 保 持 原 快 照 < / p > < / div > < / div >
< div className = "subject-section-heading" > < span className = "subject-section-icon subject-section-icon--clock" > < ClockCircleOutlined / > < / span > < div > < h4 > 基 本 资 料 与 长 期 陪 伴 方 向 < / h4 > < p > 白 名 单 资 料 只 供 后 续 新 通 话 使 用 , 历 史 通 话 保 持 原 快 照 < / p > < / div > < / div >
< div className = "subject-basics-grid" >
< label className = "subject-field" > < span > 称 呼 < / span > < input disabled = { ! canWrite } value = { name } maxLength = { 120 } onChange = { ( event ) = > setName ( event . target . value ) } required / > < / label >
< label className = "subject-field" > < span > 交 流 节 奏 < / span > < select disabled = { ! canWrite } value = { pace } onChange = { ( event ) = > setPace ( event . target . value as ConversationPace ) } > < option value = "slow" > 放 慢 语 速 < / option > < option value = "normal" > 自 然 语 速 < / option > < option value = "lively" > 轻 快 交 流 < / option > < / select > < / label >
< label className = "subject-field subject-field--wide" > < span > 背 景 资 料 < / span > < textarea disabled = { ! canWrite } value = { background } maxLength = { 500 } onChange = { ( event ) = > setBackground ( event . target . value ) } required / > < / label >
< label className = "subject-field subject-field--wide" > < span > 交 流 目 标 < / span > < input disabled = { ! canWrite } value = { goal } maxLength = { 200 } onChange = { ( event ) = > setGoal ( event . target . value ) } required / > < / label >
< label className = "subject-field" > < span > 交 流 节 奏 < / span > < select disabled = { ! canWrite } value = { pace } onChange = { ( event ) = > setPace ( event . target . value as ConversationPace ) } > < option value = "slow" > 放 慢 语 速 < / option > < option value = "normal" > 自 然 语 速 < / option > < option value = "lively" > 轻 快 交 流 < / option > < / select > < small > 每 次 新 聊 天 开 始 时 都 会 传 给 豆 包 , 控 制 语 速 和 回 应 留 白 。 < / small > < / label >
< label className = "subject-field subject-field--wide" > < span > 背 景 资 料 < / span > < textarea disabled = { ! canWrite } value = { background } maxLength = { 500 } onChange = { ( event ) = > setBackground ( event . target . value ) } required / > < small > 只 填 写 已 经 核 实 并 允 许 用 于 陪 伴 交 流 的 事 实 。 < / small > < / label >
< label className = "subject-field subject-field--wide" > < span > 长 期 陪 伴 方 向 < / span > < input disabled = { ! canWrite } value = { goal } maxLength = { 200 } onChange = { ( event ) = > setGoal ( event . target . value ) } required / > < small > 长 期 保 存 在 档 案 中 , 从 后 续 每 次 新 聊 天 开 始 生 效 。 < / small > < / label >
< / div >
< / section >
{ canWrite ? < footer className = "subject-editor-actions" > < span > { profileChanged ? "已有尚未保存的资料变更" : "资料已是最新版本" } < / span > < button type = "submit" disabled = { actionBusy || ! profileChanged || ! name . trim ( ) || ! background . trim ( ) || ! goal . trim ( ) } > 核 对 并 保 存 新 版 本 < / button > < / footer > : null }
@@ -1695,7 +1805,7 @@ function FirmwarePanel(props: FirmwarePanelProps) {
< / form >
< section className = "firmware-key-ledger" aria-label = "可信固件公钥台账" >
< div className = "section-title" > < div > < strong > 可 信 固 件 公 钥 台 账 < / strong > < p > 密 钥 按 客 户 组 织 共 享 ; 页 面 只 展 示 指 纹 和 引 用 统 计 , 不 回 显 公 钥 正 文 。 < / p > < / div > < span > { props . signingKeys . filter ( ( item ) = > item . status === "ACTIVE" ) . length } 把 活 动 公 钥 < / span > < / div >
< div className = "section-title" > < div > < strong > 可 信 固 件 公 钥 台 账 < / strong > < p > 密 钥 按 账 号 空 间 共 享 ; 页 面 只 展 示 指 纹 和 引 用 统 计 , 不 回 显 公 钥 正 文 。 < / p > < / div > < span > { props . signingKeys . filter ( ( item ) = > item . status === "ACTIVE" ) . length } 把 活 动 公 钥 < / span > < / div >
{ props . signingKeys . length ? < div className = "stack-list" > { props . signingKeys . map ( ( item ) = > { const blocked = item . uploading_releases > 0 || item . running_rollouts > 0 ; return < article className = "firmware-key-card" key = { item . key_id } > < div > < strong > { item . key_id } < / strong > < code > { item . fingerprint_sha256 } < / code > < p > 上 传 中 { item . uploading_releases } · 已 验 证 { item . ready_releases } · 运 行 中 灰 度 { item . running_rollouts } · { formatDate ( item . created_at ) } 登 记 { item . revoked_at ? ` · ${ formatDate ( item . revoked_at ) } 吊销 ` : "" } < / p > < / div > < div className = "row-actions" > < span className = { ` status ${ item . status . toLowerCase ( ) } ` } > { item . status === "ACTIVE" ? "活动" : "已吊销" } < / span > { item . status === "ACTIVE" ? < button type = "button" className = "button-small button-danger" disabled = { props . actionBusy || blocked } title = { blocked ? "请先完成或退役上传中版本,并暂停或完成运行中灰度" : "吊销后不能用于新制品或新灰度" } onClick = { ( ) = > props . onRevokeKey ( item ) } > 吊 销 公 钥 < / button > : null } < / div > < / article > ; } ) } < / div > : < p className = "empty-copy" > 尚 未 登 记 可 信 固 件 公 钥 。 < / p > }
< / section >
@@ -1881,8 +1991,8 @@ function deviceBindingBlockerLabel(value: string) {
return ( { target_subject_required : "请选择新的使用人" , active_conversation : "设备仍有未结束通话" , pending_device_commands : "设备仍有未完成命令" , active_firmware_task : "设备仍有进行中的固件任务" } as Record < string , string > ) [ value ] ? ? value ;
}
function ConversationPanel ( { subjects , selectedID , onSelect , conversations , detail , usage , turns , summaries , media , mediaState , mediaError , grants , members , canManageRecordingAccess , actionBusy , audioURLs , loading , detailLoading , loadingMore , error , detailError , selectedConversationID , status , onStatus , engineMode , onEngineMode , from , onFrom , to , onTo , hasMore , onResetFilters , onLoadMore , onOpen , onGrant , onRevoke , onAccess } : {
subjects : Subject [ ] ; selectedID? : string ; onSelect : ( id : string ) = > void ; conversations : ConversationSummary [ ] ;
function ConversationPanel ( { subjects , selectedID , onSelect , conversations , insights , detail , usage , turns , summaries , media , mediaState , mediaError , grants , members , canManageRecordingAccess , actionBusy , audioURLs , loading , detailLoading , loadingMore , error , detailError , selectedConversationID , status , onStatus , engineMode , onEngineMode , from , onFrom , to , onTo , hasMore , onResetFilters , onLoadMore , onOpen , onGrant , onRevoke , onAccess } : {
subjects : Subject [ ] ; selectedID? : string ; onSelect : ( id : string ) = > void ; conversations : ConversationSummary [ ] ; insights : Record < string , ConversationInsight > ;
detail? : ConversationDetail ; usage? : ConversationUsage ; turns : ConversationTurn [ ] ; summaries : ConversationContentSummary [ ] ;
media? : ConversationMedia ; mediaState : MediaLoadState ; mediaError? : string ; grants : RecordingAccessGrant [ ] ; members : HouseholdMember [ ] ;
canManageRecordingAccess : boolean ; actionBusy : boolean ; audioURLs : Record < string , string > ;
@@ -1908,7 +2018,7 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, deta
< div className = "conversation-filter__summary" > < span > 已 加 载 { conversations . length } 条 { hasMore ? ",还有更多" : "" } < / span > { status || engineMode || from || to ? < button type = "button" className = "button-small button-secondary" onClick = { onResetFilters } > 清 除 筛 选 < / button > : null } < / div >
< / div >
{ error ? < div className = "conversation-list__error" role = "alert" > { error } < / div > : null }
{ loading && conversations . length === 0 ? < StatePanel title = "正在读取通话 记录" detail = "按创建 时间倒序加载…" / > : conversations . length ? conversations . map ( ( item ) = > < button type = "button" className = { selectedConversationID === item . id ? "conversation-row active" : "conversation-row" } key = { item . id } disabled = { detailLoading && selectedConversationID === item . id } onClick = { ( ) = > { setDetailOpen ( true ) ; void onOpen ( item . id ) ; } } > < div > < strong > { formatDate ( item . created_at ) } < / strong > < span > { conversationFailureLabel ( item . failure_code ) ? ? ` ${ engineModeLabel ( item . engine_mode ) } · ${ retentionModeLabel ( item . retention_mode ) } ${ item . audio_retention_enabled ? " · 可听聊天语音" : "" } ` } < / span > < / div > < span className = { ` tag tag-- ${ item . status . toLowerCase ( ) } ` } > { conversationStatusLabel ( item . status ) } < / span > < / button > ) : < p className = "empty-copy padded" > 暂 无 通 话 记 录 。 < / p > }
{ loading && conversations . length === 0 ? < StatePanel title = "正在读取聊天 记录" detail = "按时间倒序加载…" / > : conversations . length ? conversations . map ( ( item ) = > { const insight = insights [ item . id ] ; return < button type = "button" className = { selectedConversationID === item . id ? "conversation-row active" : "conversation-row" } key = { item . id } disabled = { detailLoading && selectedConversationID === item . id } onClick = { ( ) = > { setDetailOpen ( true ) ; void onOpen ( item . id ) ; } } > < span className = { ` conversation-row__mood conversation-row__mood-- ${ insight ? . mood ? ? "steady" } ` } > < SmileOutlined / > < / span > < div > < strong > { formatDate ( item . created_at ) } · { formatDuration ( item . duration_ms ) } < / strong > < span > { insight ? . summary || conversationFailureLabel ( item . failure_code ) || "摘要整理中" } < / span > { insight ? . topics . length ? < small > { insight . topics . join ( " · " ) } < / small > : null } < / div > < span className = { ` tag tag-- ${ item . status . toLowerCase ( ) } ` } > { conversationStatusLabel ( item . status ) } < / span > < / button > ; } ) : < p className = "empty-copy padded" > 暂 无 聊 天 记 录 。 < / p > }
{ hasMore ? < div className = "conversation-list__more" > < button type = "button" className = "button-secondary" disabled = { loading || loadingMore } onClick = { ( ) = > void onLoadMore ( ) } > { loadingMore ? "正在加载…" : "加载更多记录" } < / button > < / div > : null }
< / div >
{ detailOpen ? < div className = "workspace-dialog-backdrop" role = "presentation" onMouseDown = { ( event ) = > { if ( event . currentTarget === event . target ) setDetailOpen ( false ) ; } } > < section className = "conversation-detail workspace-dialog workspace-dialog--wide" role = "dialog" aria-modal = "true" aria-labelledby = "conversation-detail-title" > < header > < div > < h3 id = "conversation-detail-title" > 通 话 详 情 < / h3 > < p > 查 看 文 字 内 容 、 整 理 结 果 、 语 音 授 权 与 状 态 历 史 < / p > < / div > < button type = "button" aria-label = "关闭" onClick = { ( ) = > setDetailOpen ( false ) } > < CloseOutlined / > < / button > < / header > < div className = "workspace-dialog__body" > { detailLoading ? < StatePanel title = "正在读取通话详情" detail = "正在核验状态、最终话轮、整理结果、用量和录音访问权限…" / > : detailError && selectedConversationID ? < StatePanel title = "通话详情读取失败" detail = { detailError } action = { ( ) = > void onOpen ( selectedConversationID ) } / > : detail ? < >
@@ -2217,7 +2327,6 @@ function PrivacyPanel({ subjects, selectedID, onSelect, requests, exports, conse
< / div > ;
}
function Metric ( { label , value , detail } : { label : string ; value : number ; detail : string } ) { return < article className = "metric" > < span > { label } < / span > < strong > { value } < / strong > < p > { detail } < / p > < / article > ; }
function Info ( { label , value } : { label : string ; value : string } ) { return < div className = "info" > < span > { label } < / span > < strong > { value } < / strong > < / div > ; }
function TabButton ( { active , onClick , children } : { active : boolean ; onClick : ( ) = > void ; children : ReactNode } ) { return < button type = "button" className = { active ? "tab-button active" : "tab-button" } onClick = { onClick } > { children } < / button > ; }
function StatePanel ( { title , detail , action , actionLabel = "重新加载" } : { title : string ; detail : string ; action ? : ( ) = > void ; actionLabel? : string } ) { return < div className = "state-panel" > < strong > { title } < / strong > < p > { detail } < / p > { action ? < button type = "button" className = "button-secondary" onClick = { action } > { actionLabel } < / button > : null } < / div > ; }
@@ -2271,13 +2380,13 @@ function familyControlAcknowledgement(draft: FamilyControlDraft) {
if ( draft . kind === "firmware_release_resume_distribution" ) return ` 我确认版本“ ${ draft . item . version } ”的签名密钥仍可信且缺陷已处置;恢复只会让版本重新进入新灰度候选,不会自动重启旧的已暂停批次或重新投递已取消命令 ` ;
if ( draft . kind === "firmware_release_retire" ) return draft . item . status === "UPLOADING" ? ` 我确认版本“ ${ draft . item . version } ”的上传或签名校验无法继续;退役后该版本永久不能进入灰度候选,平台会尝试清理已上传对象,但此前签发的上传地址最多仍可能在原15分钟窗口内有效且不会使退役版本恢复 ` : ` 我确认永久退役已验证版本“ ${ draft . item . version } ”;它将不再进入新灰度或签发新下载地址,平台保留已验证制品、清单、历史批次和审计,已经签发的下载地址最多仍可能在原5分钟窗口内有效 ` ;
if ( draft . kind === "firmware_rollout_start" ) return ` 我确认所选 ${ draft . devices . length } 台候选设备与已验证固件的型号、主板和注册状态匹配;批次会立即进入运行状态并按 ${ draft . percentage } %确定性命中至少1台设备,暂停只能取消尚未投递的命令,已经下载或安装的设备仍需继续观察 ` ;
return ( { household_create : "我确认将在所选客户组织 内创建独立家庭空间 ,当前账号立即成为所有者并获得成员、长者和设备管理能力;本操作不会自动创建长者档案、授权或绑定设备" , invitation_accept : draft.kind === "invitation_accept" ? ` 我确认以当前登录身份“ ${ draft . accountDisplayName } ”加入预览所示客户组织和 家庭,并获得 ${ roleLabel ( draft . preview . role ) } 能力;正式提交会再次校验邀请未使用、未撤销、未过期且当前账号尚不是成员 ` : "" , subject_create : "我已核对称呼、背景、兴趣、交流目标 和优先话题,确认这些个人资料可以作为长期档案保存,并仅从后续新通话开始按档案版本使用;创建档案本身不代表已授予通话、录音、故事或记忆权限" , fact_create : "我确认该内容来自家庭成员录入,类型和敏感等级已正确标注;创建后仅作为待核对候选保存,未经后续人工确认不会作为已确认背景参与新通话" , consent_withdraw : "我确认该授权将立即失效;后续新通话不再使用,若当前通话正在依赖该授权,平台会安全停止并通知设备" , member_remove : "" , ownership_transfer : "" , member_role_change : "" , invitation_create : draft.kind === "invitation_create" ? ` 我确认接收人提示不绑定真实身份,实际持有凭据的登录账号会以 ${ roleLabel ( draft . role ) } 加入家庭;凭据只显示一次、七天内可使用,应通过受控渠道交付,泄露时立即撤销 ` : "" , invitation_revoke : "我确认该待接受邀请的一次性凭据将立即失效,接收人之后不能再用它加入家庭;已经存在的家庭成员关系不受影响" , fact_confirm : "我已核对事实内容、来源和敏感级别,确认它不是未经复核的模型推断;确认后将作为已核对背景参与后续新通话,不会改写历史通话" , recording_access_grant : "我确认该能力覆盖当前长者在有效期内可访问的全部已验证原录音,而不是只覆盖当前通话;同一成员已有的同类有效授权将被替换,家庭角色本身仍不会获得录音能力" , recording_access_revoke : "我确认后续不会再为该成员签发新的录音播放或下载地址;已经签发的地址最长可能在5分钟窗口内继续有效,已经取得的离线副本无法由平台收回" , recording_play_address : "我确认本次只为固定录音分片申请5分钟播放地址,播放行为会记录用途与访问审计;播放时应避免让无关人员听到长者和家庭的私密内容" , recording_download_address : "我确认本次将下载固定录音分片,下载行为会记录用途与访问审计;本地文件可能包含长者和家庭私密声音,离开平台后由我负责受控保存、转交和销毁" , privacy_access_request : "我确认盘点范围包含当前长者的八类数据;结果只保存分类数量和处理状态,不会把档案、字幕、故事或记忆正文复制到隐私请求、审计或普通日志" , privacy_export_request : "我确认导出文件将包含当前长者八类数据中的私密正文,并将只通过短期地址下载、在受控位置保存和按需销毁;本操作会记录申请人与导出审计" , privacy_export_download : "我确认本次将为固定私有导出任务申请短期下载地址;JSON文件包含个人正文,离开平台后由我负责在受控位置保存、限制转交并按需销毁" , device_claim : "我已当面核对设备屏幕、设备型号和使用人,确认把该未绑定设备永久关联到当前家庭和长者;绑定码仅用于消费且使用后失效,错误目标不能通过再次输入同一码自行改绑" , device_diagnostics : "我确认向该设备下发音频、网络和存储三项诊断命令;命令有效期5分钟,离线设备可能在过期前无法领取,本操作不会重启设备或修改设备配置" , firmware_pause : "我确认尚未投递的设备命令将被取消,已经进入下载或安装的设备状态仍需继续观察" , firmware_rollback : "我确认所选版本已经验证,回滚会创建新的批次并向目标设备投递版本变更命令" , sharing_grant_create : draft.kind === "sharing_grant_create" && ! draft . input . login_required ? "我确认该匿名口令在有效期内等同访问凭据,任何取得地址和口令的人都可能访问该固定资源;口令只显示一次,应与地址分开安全转交,泄露时立即撤销分享" : "我确认该分享只允许指定接收人登录后访问固定资源,能力和有效期已核对;同一接收人、资源和权限已有的有效分享将被替换" , sharing_revoke : "我确认后续使用该分享凭据或登录授权的资源请求将立即被拒绝,已经取得的离线副本无法由平台收回" } as const ) [ draft . kind ] ;
return ( { household_create : "我确认将在当前账号空间 内创建独立家庭,当前账号立即成为所有者并获得成员、长者和设备管理能力;本操作不会自动创建长者档案、授权或绑定设备" , invitation_accept : draft.kind === "invitation_accept" ? ` 我确认以当前登录身份“ ${ draft . accountDisplayName } ”加入预览所示家庭,并获得 ${ roleLabel ( draft . preview . role ) } 能力;正式提交会再次校验邀请未使用、未撤销、未过期且当前账号尚不是成员 ` : "" , subject_create : "我已核对称呼、背景、兴趣、长期陪伴方向 和优先话题,确认这些个人资料可以作为长期档案保存,并仅从后续新通话开始按档案版本使用;创建档案本身不代表已授予通话、录音、故事或记忆权限" , fact_create : "我确认该内容来自家庭成员录入,类型和敏感等级已正确标注;创建后仅作为待核对候选保存,未经后续人工确认不会作为已确认背景参与新通话" , consent_withdraw : "我确认该授权将立即失效;后续新通话不再使用,若当前通话正在依赖该授权,平台会安全停止并通知设备" , member_remove : "" , ownership_transfer : "" , member_role_change : "" , invitation_create : draft.kind === "invitation_create" ? ` 我确认接收人提示不绑定真实身份,实际持有凭据的登录账号会以 ${ roleLabel ( draft . role ) } 加入家庭;凭据只显示一次、七天内可使用,应通过受控渠道交付,泄露时立即撤销 ` : "" , invitation_revoke : "我确认该待接受邀请的一次性凭据将立即失效,接收人之后不能再用它加入家庭;已经存在的家庭成员关系不受影响" , fact_confirm : "我已核对事实内容、来源和敏感级别,确认它不是未经复核的模型推断;确认后将作为已核对背景参与后续新通话,不会改写历史通话" , recording_access_grant : "我确认该能力覆盖当前长者在有效期内可访问的全部已验证原录音,而不是只覆盖当前通话;同一成员已有的同类有效授权将被替换,家庭角色本身仍不会获得录音能力" , recording_access_revoke : "我确认后续不会再为该成员签发新的录音播放或下载地址;已经签发的地址最长可能在5分钟窗口内继续有效,已经取得的离线副本无法由平台收回" , recording_play_address : "我确认本次只为固定录音分片申请5分钟播放地址,播放行为会记录用途与访问审计;播放时应避免让无关人员听到长者和家庭的私密内容" , recording_download_address : "我确认本次将下载固定录音分片,下载行为会记录用途与访问审计;本地文件可能包含长者和家庭私密声音,离开平台后由我负责受控保存、转交和销毁" , privacy_access_request : "我确认盘点范围包含当前长者的八类数据;结果只保存分类数量和处理状态,不会把档案、字幕、故事或记忆正文复制到隐私请求、审计或普通日志" , privacy_export_request : "我确认导出文件将包含当前长者八类数据中的私密正文,并将只通过短期地址下载、在受控位置保存和按需销毁;本操作会记录申请人与导出审计" , privacy_export_download : "我确认本次将为固定私有导出任务申请短期下载地址;JSON文件包含个人正文,离开平台后由我负责在受控位置保存、限制转交并按需销毁" , device_claim : "我已当面核对设备屏幕、设备型号和使用人,确认把该未绑定设备永久关联到当前家庭和长者;绑定码仅用于消费且使用后失效,错误目标不能通过再次输入同一码自行改绑" , device_diagnostics : "我确认向该设备下发音频、网络和存储三项诊断命令;命令有效期5分钟,离线设备可能在过期前无法领取,本操作不会重启设备或修改设备配置" , firmware_pause : "我确认尚未投递的设备命令将被取消,已经进入下载或安装的设备状态仍需继续观察" , firmware_rollback : "我确认所选版本已经验证,回滚会创建新的批次并向目标设备投递版本变更命令" , sharing_grant_create : draft.kind === "sharing_grant_create" && ! draft . input . login_required ? "我确认该匿名口令在有效期内等同访问凭据,任何取得地址和口令的人都可能访问该固定资源;口令只显示一次,应与地址分开安全转交,泄露时立即撤销分享" : "我确认该分享只允许指定接收人登录后访问固定资源,能力和有效期已核对;同一接收人、资源和权限已有的有效分享将被替换" , sharing_revoke : "我确认后续使用该分享凭据或登录授权的资源请求将立即被拒绝,已经取得的离线副本无法由平台收回" } as const ) [ draft . kind ] ;
}
function memberRoleImpact ( role : "admin" | "editor" | "viewer" ) { return ( { admin : "管理员可以管理家庭成员、长者资料、普通设备配置和故事审核,但录音与高敏感资源仍需独立授权。" , editor : "编辑者可以修改长者资料、普通设备配置和审核故事,但不能管理家庭成员或提交隐私删除。" , viewer : "查看者只能查看家庭、长者和通话文字等允许内容,不能修改长者资料、审核故事或管理设备。" } as const ) [ role ] ; }
function familyControlTarget ( draft : FamilyControlDraft ) : ReactNode {
if ( draft . kind === "household_create" ) return < > 客 户 组 织 < strong > { draft . tenantName } < / strong > ( { draft . tenantID } ) · 当 前 账 号 < strong > { draft . accountDisplayName } < / strong > · 新 家 庭 < strong > { draft . householdName } < / strong > 。 提 交 后 服 务 端 重 新 校 验 该 登 录 身 份 在 客 户 组 织 内 的 活 动 账 号 , 并 在 同 一 事 务 创 建 家 庭 、 所 有 者 成 员 关 系 和 审 计 记 录 ; 家 庭 初 始 不 包 含 长 者 、 授 权 、 设 备 或 通 话 。 < / > ;
if ( draft . kind === "invitation_accept" ) return < > 当 前 登 录 身 份 < strong > { draft . accountDisplayName } < / strong > · 目 标 客 户 组 织 < strong > { draft . preview . tenant_name } < / strong > ( { draft . preview . tenant_id } ) · 目 标 家 庭 < strong > { draft . preview . household_name } < / strong > ( { draft . preview . household_id } ) · 接 收 人 提 示 { draft . preview . invitee_hint || "未填写" } · 加 入 角 色 < strong > { roleLabel ( draft . preview . role ) } < / strong > · { formatDate ( draft . preview . expires_at ) } 到 期 · 凭 据 { maskedSecret ( draft . token ) } 。 本 次 预 览 不 创 建 账 号 或 成 员 关 系 , 正 式 提 交 时 服 务 端 再 次 原 子 校 验 。 < / > ;
if ( draft . kind === "subject_create" ) return < > 家 庭 < strong > { draft . household . name } < / strong > ( { draft . household . id } ) · 长 者 称 呼 < strong > { draft . input . preferred_name } < / strong > · 语 言 { draft . input . language } · 时 区 { draft . input . timezone } · 背 景 < strong > { draft . input . profile_data . background || "未填写" } < / strong > · 兴 趣 { contextItemsLabel ( draft . input . profile_data . interests ) } · 交 流 目 标 < strong > { draft . input . conversation_preferences . conversation_goal || "未填写" } < / strong > · 节 奏 { conversationPaceLabel ( draft . input . conversation_preferences . pace ) } · 优 先 话 题 { contextItemsLabel ( draft . input . conversation_preferences . preferred_topics ) } · 避 免 主 动 提 及 { contextItemsLabel ( draft . input . conversation_preferences . avoided_topics ) } 。 服 务 端 将 在 同 一 事 务 创 建 档 案 和 审 计 记 录 ; 授 权 、 设 备 绑 定 和 通 话 需 分 别 完 成 。 < / > ;
if ( draft . kind === "household_create" ) return < > 账 号 空 间 < strong > { draft . tenantName } < / strong > · 当 前 账 号 < strong > { draft . accountDisplayName } < / strong > · 新 家 庭 < strong > { draft . householdName } < / strong > 。 提 交 后 服 务 端 重 新 校 验 登 录 身 份 , 并 在 同 一 事 务 创 建 家 庭 、 所 有 者 成 员 关 系 和 审 计 记 录 ; 家 庭 初 始 不 包 含 长 者 、 授 权 、 设 备 或 通 话 。 < / > ;
if ( draft . kind === "invitation_accept" ) return < > 当 前 登 录 身 份 < strong > { draft . accountDisplayName } < / strong > · 目 标 家 庭 < strong > { draft . preview . household_name } < / strong > · 接 收 人 提 示 { draft . preview . invitee_hint || "未填写" } · 加 入 角 色 < strong > { roleLabel ( draft . preview . role ) } < / strong > · { formatDate ( draft . preview . expires_at ) } 到 期 · 凭 据 { maskedSecret ( draft . token ) } 。 本 次 预 览 不 创 建 成 员 关 系 , 正 式 提 交 时 服 务 端 再 次 原 子 校 验 。 < / > ;
if ( draft . kind === "subject_create" ) return < > 家 庭 < strong > { draft . household . name } < / strong > ( { draft . household . id } ) · 长 者 称 呼 < strong > { draft . input . preferred_name } < / strong > · 语 言 { draft . input . language } · 时 区 { draft . input . timezone } · 背 景 < strong > { draft . input . profile_data . background || "未填写" } < / strong > · 兴 趣 { contextItemsLabel ( draft . input . profile_data . interests ) } · 长 期 陪 伴 方 向 < strong > { draft . input . conversation_preferences . conversation_goal || "未填写" } < / strong > · 节 奏 { conversationPaceLabel ( draft . input . conversation_preferences . pace ) } · 优 先 话 题 { contextItemsLabel ( draft . input . conversation_preferences . preferred_topics ) } · 避 免 主 动 提 及 { contextItemsLabel ( draft . input . conversation_preferences . avoided_topics ) } 。 服 务 端 将 在 同 一 事 务 创 建 档 案 和 审 计 记 录 ; 授 权 、 设 备 绑 定 和 通 话 需 分 别 完 成 。 < / > ;
if ( draft . kind === "fact_create" ) return < > 长 者 < strong > { draft . subjectName } < / strong > ( { draft . subjectID } ) · 类 型 < strong > { factTypeLabel ( draft . input . fact_type ) } < / strong > · 内 容 < strong > { draft . input . content } < / strong > · 来 源 家 庭 成 员 录 入 ( { draft . input . source_ref || "未登记引用" } ) · 敏 感 等 级 < strong > { factSensitivityLabel ( draft . input . sensitivity ) } < / strong > · 初 始 状 态 < strong > 待 核 对 < / strong > 。 本 次 只 保 存 候 选 事 实 和 审 计 , 不 会 自 动 确 认 、 创 建 记 忆 或 改 写 历 史 通 话 。 < / > ;
if ( draft . kind === "subject_update" ) return < > 长 者 < strong > { draft . item . preferred_name } < / strong > ( { draft . item . id } ) · 当 前 档 案 v { draft . item . version } → 新 档 案 v { draft . item . version + 1 } · 变 更 < strong > { subjectProfileChangeSummary ( draft . item , draft . input ) . join ( "; " ) } < / strong > 。 服 务 端 使 用 当 前 强 ETag重新校验版本 ; 若 其 他 成 员 已 先 修 改 , 提 交 会 被 拒 绝 而 不 会 覆 盖 新 数 据 。 < / > ;
if ( draft . kind === "device_twin_update" ) return < > 设 备 < strong > { draft . item . model } < / strong > ( { draft . item . id } ) · 长 者 { draft . item . subject_name } ( { draft . item . subject_id } ) · 扬 声 器 期 望 值 < strong > { draft . previousVolume === undefined ? "未设置" : ` ${ draft . previousVolume } % ` } → { draft . speakerVolume } % < / strong > · 设 备 上 报 实 际 值 { deviceSpeakerVolume ( draft . twin . reported_config ) === undefined ? "未上报" : ` ${ deviceSpeakerVolume ( draft . twin . reported_config ) } % ` } · 期 望 配 置 v { draft . twin . desired_revision } → v { draft . twin . desired_revision + 1 } 。 服 务 端 只 接 受 白 名 单 非 敏 感 配 置 并 使 用 强 ETag ; 保 存 后 需 等 待 设 备 领 取 和 上 报 。 < / > ;
@@ -2294,7 +2403,7 @@ function familyControlTarget(draft: FamilyControlDraft): ReactNode {
if ( draft . kind === "member_remove" ) return < > 家 庭 < strong > { draft . household . name } < / strong > ( { draft . household . id } ) · 成 员 < strong > { draft . item . display_name } < / strong > · { roleLabel ( draft . item . role ) } · 成 员 编 号 { draft . item . id } · 记 录 版 本 v { draft . item . version } 。 移 出 后 该 成 员 将 失 去 家 庭 、 长 者 、 设 备 和 内 容 访 问 能 力 。 < / > ;
if ( draft . kind === "ownership_transfer" ) return < > 家 庭 < strong > { draft . household . name } < / strong > ( { draft . household . id } ) · 新 所 有 者 < strong > { draft . item . display_name } < / strong > · 当 前 角 色 { roleLabel ( draft . item . role ) } · 成 员 编 号 { draft . item . id } · 记 录 版 本 v { draft . item . version } 。 转 移 后 当 前 所 有 者 将 变 为 管 理 员 。 < / > ;
if ( draft . kind === "member_role_change" ) return < > 家 庭 < strong > { draft . household . name } < / strong > ( { draft . household . id } ) · 成 员 < strong > { draft . item . display_name } < / strong > · 当 前 { roleLabel ( draft . item . role ) } · 成 员 编 号 { draft . item . id } · 记 录 版 本 v { draft . item . version } 。 角 色 变 化 只 调 整 一 般 家 庭 能 力 , 不 会 隐 式 授 予 录 音 播 放 、 下 载 或 其 他 高 敏 感 资 源 权 限 。 < / > ;
if ( draft . kind === "invitation_create" ) return < > 家 庭 < strong > { draft . household . name } < / strong > ( { draft . household . id } ) · 接 收 人 提 示 < strong > { draft . inviteeHint || "未填写" } < / strong > · 加 入 角 色 < strong > { roleLabel ( draft . role ) } < / strong > · 固 定 有 效 期 { draft . expiresInHours / 24 } 天 。 接 收 人 提 示 仅 用 于 人 工 识 别 , 不 校 验 接 受 者 身 份 ; 实 际 持 有 一 次 性 凭 据 的 登 录 账 号 会 加 入 当 前 客 户 组 织 和 家 庭 , 并 获 得 该 角 色 对 应 能 力 。 凭 据 明 文 只 在 创 建 成 功 后 显 示 一 次 , 服 务 端 只 保 存 摘 要 。 < / > ;
if ( draft . kind === "invitation_create" ) return < > 家 庭 < strong > { draft . household . name } < / strong > ( { draft . household . id } ) · 接 收 人 提 示 < strong > { draft . inviteeHint || "未填写" } < / strong > · 加 入 角 色 < strong > { roleLabel ( draft . role ) } < / strong > · 固 定 有 效 期 { draft . expiresInHours / 24 } 天 。 接 收 人 提 示 仅 用 于 人 工 识 别 , 不 校 验 接 受 者 身 份 ; 实 际 持 有 一 次 性 凭 据 的 登 录 账 号 会 加 入 当 前 家 庭 , 并 获 得 该 角 色 对 应 能 力 。 凭 据 明 文 只 在 创 建 成 功 后 显 示 一 次 , 服 务 端 只 保 存 摘 要 。 < / > ;
if ( draft . kind === "invitation_revoke" ) return < > 家 庭 < strong > { draft . household . name } < / strong > ( { draft . household . id } ) · 邀 请 < strong > { draft . item . invitee_hint || "未填写接收人提示" } < / strong > · { roleLabel ( draft . item . role ) } · 邀 请 编 号 { draft . item . id } · { formatDate ( draft . item . expires_at ) } 到 期 · 当 前 { invitationStatusLabel ( draft . item . status ) } 。 撤 销 后 原 一 次 性 凭 据 立 即 失 效 。 < / > ;
if ( draft . kind === "fact_confirm" ) return < > 长 者 < strong > { draft . subjectName } < / strong > ( { draft . subjectID } ) · 事 实 < strong > { draft . item . fact_type } < / strong > · { draft . item . content } · 来 源 { factSourceLabel ( draft . item . source_type ) } { draft . item . source_ref ? ` ( ${ draft . item . source_ref } ) ` : "(未登记来源引用)" } · { factSensitivityLabel ( draft . item . sensitivity ) } · 当 前 { factStatusLabel ( draft . item . confirmation_status ) } · 记 录 版 本 v { draft . item . version } 。 < / > ;
if ( draft . kind === "recording_access_grant" ) return < > 长 者 < strong > { draft . subjectName } < / strong > ( { draft . subjectID } ) · 接 收 成 员 < strong > { draft . granteeName } < / strong > ( { draft . input . grantee_user_id } ) · 能 力 < strong > { draft . input . permission === "PLAY" ? "播放" : "下载" } < / strong > · 用 途 { draft . input . purpose } · { formatDate ( draft . input . expires_at ) } 到 期 。 该 能 力 按 长 者 生 效 , 可 用 于 有 效 期 内 当 前 及 后 续 可 访 问 的 已 验 证 录 音 ; 服 务 端 会 撤 销 该 成 员 原 有 的 同 类 有 效 授 权 后 创 建 新 授 权 。 < / > ;
@@ -2313,10 +2422,78 @@ function errorMessage(error: unknown) { return error instanceof Error ? error.me
function maskedSecret ( value : string ) { const suffix = value . slice ( - 4 ) ; return ` •••••••• ${ suffix } (共 ${ value . length } 字符) ` ; }
function sharingInputResourceLabel ( input : SharingGrantInput , stories : Story [ ] , conversations : ConversationSummary [ ] , media? : ConversationMedia ) { if ( input . resource_type === "STORY" ) return ` 故事: ${ stories . find ( ( item ) = > item . id === input . resource_id ) ? . title ? ? input . resource_id } ` ; if ( input . resource_type === "TRANSCRIPT" ) { const item = conversations . find ( ( candidate ) = > candidate . id === input . resource_id ) ; return item ? ` 通话文字: ${ formatDate ( item . created_at ) } · ${ conversationStatusLabel ( item . status ) } ` : ` 通话文字: ${ input . resource_id } ` ; } const item = media ? . segments . find ( ( candidate ) = > candidate . id === input . resource_id ) ; return item ? ` 录音分片: ${ item . track === "near" ? "长者" : "智能体" } 轨道 # ${ item . sequence_no } ` : ` 录音分片: ${ input . resource_id } ` ; }
function exportTaskCategories ( task : ExportTask ) { const value = task . scope . categories ; const categories = Array . isArray ( value ) ? value . filter ( ( item ) : item is string = > typeof item === "string" ) : [ ] ; return categories . length ? categories : privacyCategories ( ) ; }
function SubjectAvatar ( { subject , index } : { subject : Subject ; index : number } ) {
const uploaded = profileString ( subject . profile_data , "avatar_data_url" ) ;
const fallbacks = [ "/assets/elder-lin.png" , "/assets/family-chen.png" , "/assets/family-wang.png" ] ;
return < img src = { uploaded || fallbacks [ Math . abs ( index ) % fallbacks . length ] } alt = { ` ${ subject . preferred_name } 头像 ` } / > ;
}
function inferConversationInsight ( items : ConversationContentSummary [ ] ) : ConversationInsight {
const preferred = items . find ( ( item ) = > item . type === "conversation_summary" ) ? ? items [ 0 ] ;
const summary = preferred ? . content . trim ( ) ? ? "" ;
const text = items . map ( ( item ) = > item . content ) . join ( " " ) ;
const attentionWords = [ "孤独" , "寂寞" , "睡不着" , "失眠" , "没胃口" , "不想吃" , "难过" , "担心" , "焦虑" , "疲惫" , "不舒服" ] ;
const positiveWords = [ "开心" , "高兴" , "喜欢" , "不错" , "散步" , "花开" , "期待" , "舒心" , "愉快" , "精神好" ] ;
const attentionMatches = attentionWords . filter ( ( word ) = > text . includes ( word ) ) ;
const positiveMatches = positiveWords . filter ( ( word ) = > text . includes ( word ) ) ;
const mood = attentionMatches . length ? "attention" : positiveMatches . length ? "positive" : "steady" ;
const topicWords = [ "春天" , "散步" , "花" , "家乡" , "童年" , "工作" , "朋友" , "孙辈" , "饮食" , "睡眠" , "天气" , "戏曲" , "做饭" , "电视" , "节日" ] ;
const topics = topicWords . filter ( ( word ) = > text . includes ( word ) ) . slice ( 0 , 3 ) ;
return {
summary : summary || "本次聊天已结束,摘要仍在整理中。" ,
topics ,
mood ,
moodLabel : mood === "attention" ? "需要留意" : mood === "positive" ? "平稳偏积极" : "平稳" ,
evidence : attentionMatches.length ? ` 聊天摘要提到“ ${ attentionMatches . slice ( 0 , 2 ) . join ( "、" ) } ” ` : positiveMatches . length ? ` 聊天摘要里有“ ${ positiveMatches . slice ( 0 , 2 ) . join ( "、" ) } ”等积极表达 ` : "摘要中的表达整体平稳"
} ;
}
function conversationWeek ( conversations : ConversationSummary [ ] , insights : Record < string , ConversationInsight > ) {
const result = Array . from ( { length : 7 } , ( _ , index ) = > {
const date = new Date ( ) ;
date . setHours ( 0 , 0 , 0 , 0 ) ;
date . setDate ( date . getDate ( ) - ( 6 - index ) ) ;
const key = localDateKey ( date ) ;
const matches = conversations . filter ( ( conversation ) = > localDateKey ( new Date ( conversation . started_at ? ? conversation . created_at ) ) === key ) ;
const latest = matches [ 0 ] ;
return {
key ,
dateLabel : ` ${ date . getMonth ( ) + 1 } / ${ date . getDate ( ) } ` ,
weekday : new Intl . DateTimeFormat ( "zh-CN" , { weekday : "short" } ) . format ( date ) ,
durationMS : matches.reduce ( ( sum , item ) = > sum + Number ( item . duration_ms ? ? 0 ) , 0 ) ,
insight : latest ? ( insights [ latest . id ] ? ? inferConversationInsight ( [ ] ) ) : inferConversationInsight ( [ ] ) ,
isToday : index === 6
} ;
} ) ;
return result ;
}
function weekMaxDuration ( week : ReturnType < typeof conversationWeek > ) { return Math . max ( . . . week . map ( ( item ) = > item . durationMS ) , 1 ) ; }
function localDateKey ( date : Date ) { return ` ${ date . getFullYear ( ) } - ${ date . getMonth ( ) + 1 } - ${ date . getDate ( ) } ` ; }
function formatDuration ( value? : number ) { if ( ! value ) return "时长待确认" ; const minutes = Math . max ( 1 , Math . round ( value / 60000 ) ) ; return ` ${ minutes } 分钟 ` ; }
async function prepareAvatarDataURL ( file : File ) {
if ( ! file . type . match ( /^image\/(jpeg|png|webp)$/ ) ) throw new Error ( "请选择 JPG、PNG 或 WebP 图片" ) ;
if ( file . size > 8 * 1024 * 1024 ) throw new Error ( "原图不能超过 8MB" ) ;
const source = await fileToDataURL ( file ) ;
const image = await loadImage ( source ) ;
const side = Math . min ( image . naturalWidth , image . naturalHeight ) ;
const canvas = document . createElement ( "canvas" ) ;
canvas . width = 320 ;
canvas . height = 320 ;
const context = canvas . getContext ( "2d" ) ;
if ( ! context ) throw new Error ( "当前浏览器无法处理头像" ) ;
context . drawImage ( image , ( image . naturalWidth - side ) / 2 , ( image . naturalHeight - side ) / 2 , side , side , 0 , 0 , 320 , 320 ) ;
const blob = await new Promise < Blob | null > ( ( resolve ) = > canvas . toBlob ( resolve , "image/webp" , 0.82 ) ) ;
if ( ! blob ) throw new Error ( "头像处理失败,请更换图片重试" ) ;
if ( blob . size > 420 * 1024 ) throw new Error ( "头像处理后仍然过大,请选择更清晰简洁的图片" ) ;
return fileToDataURL ( blob ) ;
}
function fileToDataURL ( file : Blob ) { return new Promise < string > ( ( resolve , reject ) = > { const reader = new FileReader ( ) ; reader . onload = ( ) = > resolve ( String ( reader . result ) ) ; reader . onerror = ( ) = > reject ( new Error ( "图片读取失败" ) ) ; reader . readAsDataURL ( file ) ; } ) ; }
function loadImage ( source : string ) { return new Promise < HTMLImageElement > ( ( resolve , reject ) = > { const image = new Image ( ) ; image . onload = ( ) = > resolve ( image ) ; image . onerror = ( ) = > reject ( new Error ( "图片格式无法识别" ) ) ; image . src = source ; } ) ; }
function profileString ( value : Record < string , unknown > , key : string ) { return typeof value [ key ] === "string" ? value [ key ] as string : "" ; }
function profilePace ( value : Record < string , unknown > ) : ConversationPace { const pace = profileString ( value , "pace" ) ; return pace === "normal" || pace === "lively" ? pace : "slow" ; }
function profileList ( value : Record < string , unknown > , key : string ) { return Array . isArray ( value [ key ] ) ? ( value [ key ] as unknown [ ] ) . filter ( ( item ) : item is string = > typeof item === "string" ) : [ ] ; }
function splitContextList ( value : string ) { return value . split ( /[, ,]/ ) . map ( ( item ) = > item . trim ( ) ) . filter ( Boolean ) . slice ( 0 , 8 ) . map ( ( item ) = > item . slice ( 0 , 50 ) ) ; }
function splitContextList ( value : string ) { return value . split ( /[、 , ,]/ ) . map ( ( item ) = > item . trim ( ) ) . filter ( Boolean ) . slice ( 0 , 8 ) . map ( ( item ) = > item . slice ( 0 , 50 ) ) ; }
function contextItemsLabel ( value : unknown ) { return Array . isArray ( value ) && value . length ? value . filter ( ( item ) : item is string = > typeof item === "string" ) . join ( "、" ) || "未填写" : "未填写" ; }
function conversationPaceLabel ( value : unknown ) { return ( { slow : "放慢语速" , normal : "自然语速" , lively : "轻快交流" } as Record < string , string > ) [ typeof value === "string" ? value : "" ] ? ? "未填写" ; }
function subjectProfileChangeSummary ( subject : Subject , input : UpdateSubjectRequest ) {
@@ -2332,7 +2509,7 @@ function subjectProfileChangeSummary(subject: Subject, input: UpdateSubjectReque
const preferences = input . conversation_preferences ? ? subject . conversation_preferences ;
const nextGoal = profileString ( preferences , "conversation_goal" ) ;
const currentGoal = profileString ( subject . conversation_preferences , "conversation_goal" ) ;
if ( nextGoal !== currentGoal ) changes . push ( ` 交流目标 “${ currentGoal || "未填写" } ”→“ ${ nextGoal || "未填写" } ” ` ) ;
if ( nextGoal !== currentGoal ) changes . push ( ` 长期陪伴方向 “${ currentGoal || "未填写" } ”→“ ${ nextGoal || "未填写" } ” ` ) ;
const nextPace = profilePace ( preferences ) ;
const currentPace = profilePace ( subject . conversation_preferences ) ;
if ( nextPace !== currentPace ) changes . push ( ` 节奏“ ${ conversationPaceLabel ( currentPace ) } ”→“ ${ conversationPaceLabel ( nextPace ) } ” ` ) ;
@@ -2375,7 +2552,6 @@ const contentErrorTypeOptions: { value: ContentErrorType; label: string }[] = [
] ;
function consentLabel ( value : string ) { return ( { transcript_storage : "字幕保存" , raw_audio_storage : "原录音保存" , story_extraction : "故事整理" , personalized_memory : "个性化记忆" , family_sharing : "家庭分享" } as Record < string , string > ) [ value ] ? ? value ; }
function consentImpactLabel ( value : string ) { return ( { transcript_storage : "允许保存后续新通话的最终文字话轮;故事整理、个性化记忆和家庭分享仍需各自独立授权。" , raw_audio_storage : "允许保存后续新通话的长者与智能体双轨原始录音;播放和下载仍需显式录音访问能力。" , story_extraction : "允许从后续新通话文字生成待人工审核的故事候选和通话摘要;候选不会自动成为已确认故事。" , personalized_memory : "与故事整理授权同时有效时,允许在新通话中参考最近通话摘要,并生成、检索经人工确认的长期记忆;撤回后新通话停止使用摘要和现有记忆,记忆索引停止使用。" , family_sharing : "允许家庭成员按后续独立资源分享规则访问内容;本授权本身不会创建任何故事、文字或录音分享。" } as Record < string , string > ) [ value ] ? ? "" ; }
function readinessActionLabel ( value : string ) { return ( { EDIT_PROFILE : "完善档案" , MANAGE_CONSENTS : "办理授权" , BIND_DEVICE : "绑定设备" , OPEN_DEVICES : "查看设备" } as Record < string , string > ) [ value ] ? ? "立即处理" ; }
function syncLabel ( value : string ) { return ( { applied : "设备已应用" , pending : "等待设备应用" , never_reported : "设备尚未确认" } as Record < string , string > ) [ value ] ? ? value ; }
function roleLabel ( value : string ) { return ( { owner : "所有者" , admin : "管理员" , editor : "编辑者" , viewer : "查看者" } as Record < string , string > ) [ value ] ? ? value ; }
function invitationStatusLabel ( value : string ) { return ( { pending : "待接受" , used : "已使用" , revoked : "已撤销" , expired : "已过期" } as Record < string , string > ) [ value ] ? ? value ; }