2510 lines
283 KiB
TypeScript
2510 lines
283 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||
import {
|
||
BookOutlined,
|
||
ClockCircleOutlined,
|
||
CloseOutlined,
|
||
DisconnectOutlined,
|
||
DeleteOutlined,
|
||
DownOutlined,
|
||
EditOutlined,
|
||
HeartOutlined,
|
||
HomeOutlined,
|
||
HourglassOutlined,
|
||
LaptopOutlined,
|
||
MessageOutlined,
|
||
MoreOutlined,
|
||
PlusOutlined,
|
||
ReloadOutlined,
|
||
SafetyCertificateOutlined,
|
||
SearchOutlined,
|
||
SettingOutlined,
|
||
StopOutlined,
|
||
TagOutlined,
|
||
ToolOutlined,
|
||
UserAddOutlined,
|
||
UserOutlined
|
||
} from "@ant-design/icons";
|
||
import {
|
||
APIRequestError,
|
||
platformApi,
|
||
type Consent,
|
||
type ContentErrorType,
|
||
type ConversationReadiness,
|
||
type ConversationContentSummary,
|
||
type ConversationDetail,
|
||
type ConversationQuery,
|
||
type ConversationUsage,
|
||
type ConversationSummary,
|
||
type ConversationTurn,
|
||
type ConversationMedia,
|
||
type CreateSubjectFactRequest,
|
||
type CreateSubjectRequest,
|
||
type CuratedMemory,
|
||
type CurrentUser,
|
||
type DevicePresence,
|
||
type DeviceBindingImpact,
|
||
type DeviceTwin,
|
||
type ExportTask,
|
||
type FactCandidate,
|
||
type FirmwareRelease,
|
||
type FirmwareReleaseImpact,
|
||
type FirmwareRollout,
|
||
type FirmwareAssignment,
|
||
type FirmwareAssignmentEvent,
|
||
type FirmwareSigningKey,
|
||
type GrantConsentRequest,
|
||
type Household,
|
||
type HouseholdMember,
|
||
type ManagedDevice,
|
||
type MemberInvitation,
|
||
type MemberInvitationPreview,
|
||
type PrepareFirmwareReleaseRequest,
|
||
type RecordingAccessGrant,
|
||
type SharingGrant,
|
||
type PrivacyRequest,
|
||
type Subject,
|
||
type SubjectFact,
|
||
type UpdateDeviceTwinRequest,
|
||
type UpdateSubjectRequest,
|
||
type Story
|
||
} from "../api/client";
|
||
import { FamilyUsageOverview } from "../components/UsageOverview";
|
||
|
||
export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages";
|
||
type Tab = CustomerSection | "firmware";
|
||
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 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 };
|
||
type FamilyControlDraft =
|
||
| { kind: "household_create"; tenantID: string; tenantName: string; accountDisplayName: string; householdName: string; acknowledged: boolean; confirmation: string }
|
||
| { kind: "invitation_accept"; token: string; preview: MemberInvitationPreview; accountDisplayName: string; acknowledged: boolean; confirmation: string }
|
||
| { kind: "subject_create"; household: Household; input: CreateSubjectRequest; acknowledged: boolean; confirmation: string }
|
||
| { kind: "fact_create"; subjectID: string; subjectName: string; input: CreateSubjectFactRequest; acknowledged: boolean; confirmation: string }
|
||
| { kind: "subject_update"; item: Subject; input: UpdateSubjectRequest; acknowledged: boolean; confirmation: string }
|
||
| { kind: "device_twin_update"; item: ManagedDevice; twin: DeviceTwin; input: UpdateDeviceTwinRequest; previousVolume?: number; speakerVolume: number; acknowledged: boolean; confirmation: string }
|
||
| { kind: "consent_grant"; subjectID: string; subjectName: string; input: GrantConsentRequest; notice: string; acknowledged: boolean; confirmation: string }
|
||
| { kind: "firmware_key_register"; household: Household; keyID: string; publicKey: string; fingerprint: string; acknowledged: boolean; confirmation: string }
|
||
| { kind: "firmware_key_revoke"; household: Household; item: FirmwareSigningKey; acknowledged: boolean; confirmation: string }
|
||
| { kind: "firmware_release_publish"; household: Household; input: PrepareFirmwareReleaseRequest; file: File; acknowledged: boolean; confirmation: string }
|
||
| { kind: "firmware_release_resume"; household: Household; item: FirmwareRelease; file: File; acknowledged: boolean; confirmation: string }
|
||
| { kind: "firmware_release_pause_distribution"; household: Household; item: FirmwareRelease; runningRollouts: number; acknowledged: boolean; confirmation: string }
|
||
| { kind: "firmware_release_resume_distribution"; household: Household; item: FirmwareRelease; acknowledged: boolean; confirmation: string }
|
||
| { kind: "firmware_release_retire"; household: Household; item: FirmwareRelease; acknowledged: boolean; confirmation: string }
|
||
| { kind: "firmware_rollout_start"; household: Household; release: FirmwareRelease; name: string; percentage: number; devices: ManagedDevice[]; acknowledged: boolean; confirmation: string }
|
||
| { kind: "consent_withdraw"; subjectID: string; subjectName: string; item: Consent; acknowledged: boolean; confirmation: string }
|
||
| { kind: "member_remove"; household: Household; item: HouseholdMember; acknowledged: boolean; confirmation: string }
|
||
| { kind: "ownership_transfer"; household: Household; item: HouseholdMember; acknowledged: boolean; confirmation: string }
|
||
| { kind: "member_role_change"; household: Household; item: HouseholdMember; targetRole: "admin" | "editor" | "viewer"; acknowledged: boolean; confirmation: string }
|
||
| { kind: "invitation_create"; household: Household; inviteeHint: string; role: "admin" | "editor" | "viewer"; expiresInHours: number; acknowledged: boolean; confirmation: string }
|
||
| { kind: "invitation_revoke"; household: Household; item: MemberInvitation; acknowledged: boolean; confirmation: string }
|
||
| { kind: "fact_confirm"; subjectID: string; subjectName: string; item: SubjectFact; acknowledged: boolean; confirmation: string }
|
||
| { kind: "recording_access_grant"; subjectID: string; subjectName: string; granteeName: string; input: RecordingGrantInput; acknowledged: boolean; confirmation: string }
|
||
| { kind: "recording_access_revoke"; subjectID: string; subjectName: string; item: RecordingAccessGrant; granteeName: string; acknowledged: boolean; confirmation: string }
|
||
| { kind: "recording_play_address"; subjectID: string; subjectName: string; conversationID: string; item: RecordingSegment; purpose: string; acknowledged: boolean; confirmation: string }
|
||
| { kind: "recording_download_address"; subjectID: string; subjectName: string; conversationID: string; item: RecordingSegment; purpose: string; acknowledged: boolean; confirmation: string }
|
||
| { kind: "privacy_access_request"; subjectID: string; subjectName: string; categories: string[]; acknowledged: boolean; confirmation: string }
|
||
| { kind: "privacy_export_request"; subjectID: string; subjectName: string; categories: string[]; acknowledged: boolean; confirmation: string }
|
||
| { kind: "privacy_export_download"; subjectID: string; subjectName: string; categories: string[]; item: ExportTask; acknowledged: boolean; confirmation: string }
|
||
| { kind: "device_claim"; household: Household; subjectID: string; subjectName: string; claimCode: string; acknowledged: boolean; confirmation: string }
|
||
| { kind: "device_diagnostics"; item: ManagedDevice; presence?: DevicePresence; acknowledged: boolean; confirmation: string }
|
||
| { kind: "firmware_pause"; item: FirmwareRollout; acknowledged: boolean; confirmation: string }
|
||
| { kind: "firmware_rollback"; item: FirmwareRollout; release: FirmwareRelease; acknowledged: boolean; confirmation: string }
|
||
| { 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"];
|
||
section?: CustomerSection;
|
||
onSectionChange?: (section: CustomerSection) => void;
|
||
showNavigation?: boolean;
|
||
enableFirmware?: boolean;
|
||
}) {
|
||
const [tab, setTab] = useState<Tab>(section);
|
||
const [state, setState] = useState<LoadState>("loading");
|
||
const [error, setError] = useState<string>();
|
||
const [households, setHouseholds] = useState<Household[]>([]);
|
||
const [devices, setDevices] = useState<ManagedDevice[]>([]);
|
||
const [selectedHouseholdID, setSelectedHouseholdID] = useState<string>();
|
||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||
const [members, setMembers] = useState<HouseholdMember[]>([]);
|
||
const [invitations, setInvitations] = useState<MemberInvitation[]>([]);
|
||
const [selectedSubjectID, setSelectedSubjectID] = useState<string>();
|
||
const [facts, setFacts] = useState<SubjectFact[]>([]);
|
||
const [consents, setConsents] = useState<Consent[]>([]);
|
||
const [conversationReadiness, setConversationReadiness] = useState<ConversationReadiness>();
|
||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||
const [conversationStatus, setConversationStatus] = useState("");
|
||
const [conversationEngineMode, setConversationEngineMode] = useState("");
|
||
const [conversationFrom, setConversationFrom] = useState("");
|
||
const [conversationTo, setConversationTo] = useState("");
|
||
const [conversationNextCursor, setConversationNextCursor] = useState<string>();
|
||
const [conversationLoading, setConversationLoading] = useState(false);
|
||
const [conversationLoadingMore, setConversationLoadingMore] = useState(false);
|
||
const [conversationError, setConversationError] = useState<string>();
|
||
const [conversationDetailError, setConversationDetailError] = useState<string>();
|
||
const [selectedConversationID, setSelectedConversationID] = useState<string>();
|
||
const [subjectLoading, setSubjectLoading] = useState(false);
|
||
const [subjectError, setSubjectError] = useState<string>();
|
||
const [presence, setPresence] = useState<Record<string, DevicePresence>>({});
|
||
const [twins, setTwins] = useState<Record<string, DeviceTwin>>({});
|
||
const [detail, setDetail] = useState<ConversationDetail>();
|
||
const [conversationUsage, setConversationUsage] = useState<ConversationUsage>();
|
||
const [turns, setTurns] = useState<ConversationTurn[]>([]);
|
||
const [contentSummaries, setContentSummaries] = useState<ConversationContentSummary[]>([]);
|
||
const [conversationMedia, setConversationMedia] = useState<ConversationMedia>();
|
||
const [mediaState, setMediaState] = useState<MediaLoadState>("idle");
|
||
const [mediaError, setMediaError] = useState<string>();
|
||
const [recordingGrants, setRecordingGrants] = useState<RecordingAccessGrant[]>([]);
|
||
const [sharingGrants, setSharingGrants] = useState<SharingGrant[]>([]);
|
||
const [issuedShare, setIssuedShare] = useState<{ id: string; token?: string }>();
|
||
const [audioURLs, setAudioURLs] = useState<Record<string, string>>({});
|
||
const [stories, setStories] = useState<Story[]>([]);
|
||
const [memories, setMemories] = useState<CuratedMemory[]>([]);
|
||
const [privacyRequests, setPrivacyRequests] = useState<PrivacyRequest[]>([]);
|
||
const [exportTasks, setExportTasks] = useState<ExportTask[]>([]);
|
||
const [firmwareReleases, setFirmwareReleases] = useState<FirmwareRelease[]>([]);
|
||
const [firmwareSigningKeys, setFirmwareSigningKeys] = useState<FirmwareSigningKey[]>([]);
|
||
const [firmwareRollouts, setFirmwareRollouts] = useState<FirmwareRollout[]>([]);
|
||
const [selectedFirmwareRollout, setSelectedFirmwareRollout] = useState<FirmwareRollout>();
|
||
const [firmwareReleaseImpact, setFirmwareReleaseImpact] = useState<FirmwareReleaseImpact>();
|
||
const [firmwareImpactLoading, setFirmwareImpactLoading] = useState(false);
|
||
const [selectedStory, setSelectedStory] = useState<Story>();
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const [refreshRevision, setRefreshRevision] = useState(0);
|
||
const [actionBusy, setActionBusy] = useState(false);
|
||
const [actionMessage, setActionMessage] = useState<string>();
|
||
const [actionError, setActionError] = useState<string>();
|
||
const [familyControlDraft, setFamilyControlDraft] = useState<FamilyControlDraft>();
|
||
const [newHouseholdTenantID, setNewHouseholdTenantID] = useState(accounts[0]?.tenant_id ?? "");
|
||
const [newHouseholdName, setNewHouseholdName] = useState("");
|
||
const [newSubjectName, setNewSubjectName] = useState("");
|
||
const [newSubjectBackground, setNewSubjectBackground] = useState("");
|
||
const [newSubjectInterests, setNewSubjectInterests] = useState("");
|
||
const [newSubjectGoal, setNewSubjectGoal] = useState("");
|
||
const [newSubjectPace, setNewSubjectPace] = useState<ConversationPace>("slow");
|
||
const [newSubjectTopics, setNewSubjectTopics] = useState("");
|
||
const [newSubjectAvoidedTopics, setNewSubjectAvoidedTopics] = useState("");
|
||
const [subjectCreateOpen, setSubjectCreateOpen] = useState(false);
|
||
const [householdCreateOpen, setHouseholdCreateOpen] = useState(false);
|
||
const [invitationAcceptOpen, setInvitationAcceptOpen] = useState(false);
|
||
const [subjectOperationsOpen, setSubjectOperationsOpen] = useState(false);
|
||
const [deviceClaimOpen, setDeviceClaimOpen] = useState(false);
|
||
const [newFactType, setNewFactType] = useState("family_note");
|
||
const [newFactContent, setNewFactContent] = useState("");
|
||
const [newFactSensitivity, setNewFactSensitivity] = useState<"normal" | "sensitive" | "high">("normal");
|
||
const [newConsentType, setNewConsentType] = useState("story_extraction");
|
||
const [consentConfirmed, setConsentConfirmed] = useState(false);
|
||
const [claimCode, setClaimCode] = useState("");
|
||
const [inviteeHint, setInviteeHint] = useState("");
|
||
const [inviteRole, setInviteRole] = useState<"admin" | "editor" | "viewer">("viewer");
|
||
const [issuedInvitationToken, setIssuedInvitationToken] = useState<string>();
|
||
const [acceptInvitationToken, setAcceptInvitationToken] = useState("");
|
||
const [firmwareKeyID, setFirmwareKeyID] = useState("");
|
||
const [firmwarePublicKey, setFirmwarePublicKey] = useState("");
|
||
const [firmwareFile, setFirmwareFile] = useState<File>();
|
||
const [firmwareDigest, setFirmwareDigest] = useState("");
|
||
const [firmwareModel, setFirmwareModel] = useState("simulated-terminal");
|
||
const [firmwareHardware, setFirmwareHardware] = useState("sim-rev-a");
|
||
const [firmwareVersion, setFirmwareVersion] = useState("");
|
||
const [firmwareSignature, setFirmwareSignature] = useState("");
|
||
const [firmwareSigningKeyID, setFirmwareSigningKeyID] = useState("");
|
||
const [firmwareMinimumSecurityVersion, setFirmwareMinimumSecurityVersion] = useState("1.0.0");
|
||
const [firmwareReleaseNotes, setFirmwareReleaseNotes] = useState("");
|
||
const [rolloutReleaseID, setRolloutReleaseID] = useState("");
|
||
const [rolloutName, setRolloutName] = useState("");
|
||
const [rolloutPercentage, setRolloutPercentage] = useState(10);
|
||
const [rolloutDeviceIDs, setRolloutDeviceIDs] = useState<string[]>([]);
|
||
const [rollbackReleaseID, setRollbackReleaseID] = useState("");
|
||
const [eventStreamState, setEventStreamState] = useState<EventStreamState>("connecting");
|
||
const [lastEventAt, setLastEventAt] = useState<string>();
|
||
const [eventNotices, setEventNotices] = useState<Array<{ id: string; type: string; resourceType: string; status: string; occurredAt: string }>>([]);
|
||
const eventRefreshTimer = useRef<number | undefined>(undefined);
|
||
const lastEventRefreshAt = useRef(0);
|
||
const conversationDetailController = useRef<AbortController | undefined>(undefined);
|
||
const claimCodeIsDeviceID = isUUID(claimCode.trim());
|
||
|
||
useEffect(() => setTab(section), [section]);
|
||
|
||
function selectSection(next: CustomerSection) {
|
||
setTab(next);
|
||
onSectionChange?.(next);
|
||
}
|
||
|
||
const scheduleEventRefresh = useCallback(() => {
|
||
if (eventRefreshTimer.current !== undefined) return;
|
||
const minimumIntervalMS = 2000;
|
||
const delay = Math.max(0, minimumIntervalMS - (Date.now() - lastEventRefreshAt.current));
|
||
eventRefreshTimer.current = window.setTimeout(() => {
|
||
eventRefreshTimer.current = undefined;
|
||
lastEventRefreshAt.current = Date.now();
|
||
setRefreshRevision((current) => current + 1);
|
||
}, delay);
|
||
}, []);
|
||
|
||
useEffect(() => () => {
|
||
if (eventRefreshTimer.current !== undefined) window.clearTimeout(eventRefreshTimer.current);
|
||
conversationDetailController.current?.abort();
|
||
}, []);
|
||
|
||
const loadRoot = useCallback(async (signal?: AbortSignal) => {
|
||
setState("loading");
|
||
setError(undefined);
|
||
try {
|
||
const [householdResponse, deviceResponse] = await Promise.all([
|
||
platformApi.households(accessToken, signal),
|
||
platformApi.devices(accessToken, signal)
|
||
]);
|
||
const nextHouseholds = householdResponse.data.items;
|
||
const nextDevices = deviceResponse.data.items;
|
||
setHouseholds(nextHouseholds);
|
||
setDevices(nextDevices);
|
||
setSelectedHouseholdID((current) =>
|
||
current && nextHouseholds.some((item) => item.id === current) ? current : nextHouseholds[0]?.id
|
||
);
|
||
setState("ready");
|
||
} catch (cause) {
|
||
if (cause instanceof DOMException && cause.name === "AbortError") return;
|
||
setError(errorMessage(cause));
|
||
setState("error");
|
||
}
|
||
}, [accessToken]);
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
void loadRoot(controller.signal);
|
||
return () => controller.abort();
|
||
}, [loadRoot]);
|
||
|
||
useEffect(() => {
|
||
setNewHouseholdTenantID((current) => accounts.some((account) => account.tenant_id === current) ? current : accounts[0]?.tenant_id ?? "");
|
||
}, [accounts]);
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
const accountKey = accounts.map((account) => account.user_id).sort().join("-");
|
||
const storageKey = `web-event-cursor:${accountKey}`;
|
||
let cursor = window.sessionStorage.getItem(storageKey) ?? "";
|
||
const run = async () => {
|
||
while (!controller.signal.aborted) {
|
||
setEventStreamState(cursor ? "reconnecting" : "connecting");
|
||
try {
|
||
const response = await fetch("/api/v1/events", {
|
||
signal: controller.signal,
|
||
headers: {
|
||
Accept: "text/event-stream",
|
||
Authorization: `Bearer ${accessToken}`,
|
||
...(cursor ? { "Last-Event-ID": cursor } : {})
|
||
}
|
||
});
|
||
if (response.status === 410) {
|
||
cursor = "";
|
||
window.sessionStorage.removeItem(storageKey);
|
||
scheduleEventRefresh();
|
||
await waitForReconnect(controller.signal, 1000);
|
||
continue;
|
||
}
|
||
if (!response.ok || !response.body) throw new Error(`状态推送连接失败(HTTP ${response.status})`);
|
||
setEventStreamState("connected");
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
while (!controller.signal.aborted) {
|
||
const chunk = await reader.read();
|
||
if (chunk.done) break;
|
||
buffer += decoder.decode(chunk.value, { stream: true }).replaceAll("\r\n", "\n");
|
||
let boundary = buffer.indexOf("\n\n");
|
||
while (boundary >= 0) {
|
||
const block = buffer.slice(0, boundary);
|
||
buffer = buffer.slice(boundary + 2);
|
||
const id = block.split("\n").find((line) => line.startsWith("id: "))?.slice(4).trim();
|
||
const data = block.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
|
||
if (id && /^\d+$/.test(id) && Number(id) > Number(cursor || 0)) {
|
||
cursor = id;
|
||
window.sessionStorage.setItem(storageKey, id);
|
||
if (data) {
|
||
setLastEventAt(new Date().toISOString());
|
||
try {
|
||
const item = JSON.parse(data) as { id?: number; type?: string; resource_type?: string; status?: string; occurred_at?: string };
|
||
setEventNotices((current) => [{
|
||
id: String(item.id ?? id),
|
||
type: item.type ?? "resource.updated",
|
||
resourceType: item.resource_type ?? "resource",
|
||
status: item.status ?? "UPDATED",
|
||
occurredAt: item.occurred_at ?? new Date().toISOString()
|
||
}, ...current.filter((notice) => notice.id !== String(item.id ?? id))].slice(0, 50));
|
||
} catch {
|
||
// The event still triggers an authoritative REST refresh when a legacy payload cannot be decoded.
|
||
}
|
||
scheduleEventRefresh();
|
||
}
|
||
}
|
||
boundary = buffer.indexOf("\n\n");
|
||
}
|
||
}
|
||
} catch (cause) {
|
||
if (controller.signal.aborted || (cause instanceof DOMException && cause.name === "AbortError")) return;
|
||
setEventStreamState("reconnecting");
|
||
}
|
||
await waitForReconnect(controller.signal, 1000);
|
||
}
|
||
};
|
||
void run();
|
||
return () => controller.abort();
|
||
}, [accessToken, accounts, scheduleEventRefresh]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedHouseholdID) {
|
||
setSubjects([]);
|
||
setMembers([]);
|
||
setInvitations([]);
|
||
setSelectedSubjectID(undefined);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
setSubjectLoading(true);
|
||
setSubjectError(undefined);
|
||
const canManageMembers = households.find((item) => item.id === selectedHouseholdID)?.capabilities.includes("member.manage") ?? false;
|
||
void Promise.all([
|
||
platformApi.subjects(selectedHouseholdID, accessToken, controller.signal),
|
||
platformApi.members(selectedHouseholdID, accessToken, controller.signal),
|
||
canManageMembers ? platformApi.invitations(selectedHouseholdID, accessToken, controller.signal) : Promise.resolve(undefined)
|
||
]).then(([subjectResponse, memberResponse, invitationResponse]) => {
|
||
const items = subjectResponse.data.items;
|
||
setSubjects(items);
|
||
setMembers(memberResponse.data.items);
|
||
setInvitations(invitationResponse?.data.items ?? []);
|
||
setSelectedSubjectID((current) => current && items.some((item) => item.id === current) ? current : items[0]?.id);
|
||
})
|
||
.catch((cause) => {
|
||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setSubjectError(errorMessage(cause));
|
||
})
|
||
.finally(() => { if (!controller.signal.aborted) setSubjectLoading(false); });
|
||
return () => controller.abort();
|
||
}, [accessToken, households, refreshRevision, selectedHouseholdID]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedSubjectID) {
|
||
setFacts([]); setConsents([]); setConversationReadiness(undefined); setStories([]); setMemories([]); setPrivacyRequests([]); setExportTasks([]); setSelectedStory(undefined);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
setSubjectLoading(true);
|
||
setSubjectError(undefined);
|
||
const mayReadPrivacy = households.find((item) => item.id === selectedHouseholdID)?.capabilities.includes("privacy.read") ?? false;
|
||
void Promise.all([
|
||
platformApi.facts(selectedSubjectID, accessToken, controller.signal),
|
||
platformApi.consents(selectedSubjectID, accessToken, controller.signal),
|
||
platformApi.conversationReadiness(selectedSubjectID, accessToken, controller.signal),
|
||
platformApi.stories(selectedSubjectID, accessToken, controller.signal),
|
||
platformApi.memories(selectedSubjectID, accessToken, controller.signal),
|
||
mayReadPrivacy ? platformApi.privacyRequests(selectedSubjectID, accessToken, controller.signal) : Promise.resolve(undefined),
|
||
mayReadPrivacy ? platformApi.exportTasks(selectedSubjectID, accessToken, controller.signal) : Promise.resolve(undefined)
|
||
]).then(([factResponse, consentResponse, readinessResponse, storyResponse, memoryResponse, privacyResponse, exportResponse]) => {
|
||
setFacts(factResponse.data.items);
|
||
setConsents(consentResponse.data.items);
|
||
setConversationReadiness(readinessResponse.data);
|
||
setStories(storyResponse.data.items);
|
||
setMemories(memoryResponse.data.items);
|
||
setPrivacyRequests(privacyResponse?.data.items ?? []);
|
||
setExportTasks(exportResponse?.data.items ?? []);
|
||
}).catch((cause) => {
|
||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setSubjectError(errorMessage(cause));
|
||
}).finally(() => { if (!controller.signal.aborted) setSubjectLoading(false); });
|
||
return () => controller.abort();
|
||
}, [accessToken, households, refreshRevision, selectedHouseholdID, selectedSubjectID]);
|
||
|
||
useEffect(() => {
|
||
conversationDetailController.current?.abort();
|
||
setDetail(undefined);
|
||
setSelectedConversationID(undefined);
|
||
setConversationDetailError(undefined);
|
||
setDetailLoading(false);
|
||
setConversationUsage(undefined);
|
||
setTurns([]);
|
||
setContentSummaries([]);
|
||
setConversationMedia(undefined);
|
||
setMediaState("idle");
|
||
setAudioURLs({});
|
||
setSelectedStory(undefined);
|
||
}, [selectedSubjectID]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedSubjectID) {
|
||
setConversations([]);
|
||
setConversationNextCursor(undefined);
|
||
setConversationError(undefined);
|
||
setConversationLoading(false);
|
||
return;
|
||
}
|
||
if (conversationFrom && conversationTo && conversationFrom > conversationTo) {
|
||
setConversations([]);
|
||
setConversationNextCursor(undefined);
|
||
setConversationError("开始日期不能晚于结束日期");
|
||
setConversationLoading(false);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
setConversationLoading(true);
|
||
setConversationError(undefined);
|
||
void platformApi.conversations(selectedSubjectID, conversationQuery({
|
||
status: conversationStatus,
|
||
engineMode: conversationEngineMode,
|
||
from: conversationFrom,
|
||
to: conversationTo
|
||
}), accessToken, controller.signal).then((response) => {
|
||
setConversations(response.data.items);
|
||
setConversationNextCursor(response.data.next_cursor);
|
||
}).catch((cause) => {
|
||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setConversationError(errorMessage(cause));
|
||
}).finally(() => { if (!controller.signal.aborted) setConversationLoading(false); });
|
||
return () => controller.abort();
|
||
}, [accessToken, conversationEngineMode, conversationFrom, conversationStatus, conversationTo, refreshRevision, selectedSubjectID]);
|
||
|
||
useEffect(() => {
|
||
if (devices.length === 0) { setPresence({}); setTwins({}); return; }
|
||
const controller = new AbortController();
|
||
void Promise.all(devices.map(async (device) => {
|
||
const [presenceResponse, twinResponse] = await Promise.all([
|
||
platformApi.devicePresence(device.id, accessToken, controller.signal),
|
||
platformApi.deviceTwin(device.id, accessToken, controller.signal)
|
||
]);
|
||
return { id: device.id, presence: presenceResponse.data, twin: twinResponse.data };
|
||
})).then((results) => {
|
||
setPresence(Object.fromEntries(results.map((item) => [item.id, item.presence])));
|
||
setTwins(Object.fromEntries(results.map((item) => [item.id, item.twin])));
|
||
}).catch((cause) => {
|
||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(errorMessage(cause));
|
||
});
|
||
return () => controller.abort();
|
||
}, [accessToken, devices, refreshRevision]);
|
||
|
||
const selectedHousehold = households.find((item) => item.id === selectedHouseholdID);
|
||
const selectedSubject = subjects.find((item) => item.id === selectedSubjectID);
|
||
const canWriteSubject = selectedHousehold?.capabilities.includes("subject.write") ?? false;
|
||
const canManageDevice = selectedHousehold?.capabilities.includes("device.manage") ?? false;
|
||
const canManageMembers = selectedHousehold?.capabilities.includes("member.manage") ?? false;
|
||
const canTransferOwnership = selectedHousehold?.capabilities.includes("ownership.transfer") ?? false;
|
||
const canReviewStory = selectedHousehold?.capabilities.includes("story.review") ?? false;
|
||
const canReadPrivacy = selectedHousehold?.capabilities.includes("privacy.read") ?? false;
|
||
const canManagePrivacy = selectedHousehold?.capabilities.includes("privacy.manage") ?? false;
|
||
const canManageFirmware = enableFirmware && (selectedHousehold?.capabilities.includes("firmware.manage") ?? false);
|
||
const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
|
||
const canManageSharing = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
|
||
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]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedSubjectID || !canManageRecordingAccess) {
|
||
setRecordingGrants([]);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
void platformApi.recordingAccessGrants(selectedSubjectID, accessToken, controller.signal)
|
||
.then((response) => setRecordingGrants(response.data.items))
|
||
.catch((cause) => {
|
||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setSubjectError(errorMessage(cause));
|
||
});
|
||
return () => controller.abort();
|
||
}, [accessToken, canManageRecordingAccess, refreshRevision, selectedSubjectID]);
|
||
|
||
useEffect(() => {
|
||
setIssuedShare(undefined);
|
||
if (!selectedSubjectID || !canManageSharing) { setSharingGrants([]); return; }
|
||
const controller = new AbortController();
|
||
void platformApi.sharingGrants(selectedSubjectID, accessToken, controller.signal)
|
||
.then((response) => setSharingGrants(response.data.items))
|
||
.catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setSubjectError(errorMessage(cause)); });
|
||
return () => controller.abort();
|
||
}, [accessToken, canManageSharing, refreshRevision, selectedSubjectID]);
|
||
|
||
useEffect(() => {
|
||
setFirmwareReleaseImpact(undefined);
|
||
}, [selectedHouseholdID]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedHouseholdID || !canManageFirmware) {
|
||
setFirmwareSigningKeys([]);
|
||
setFirmwareReleases([]);
|
||
setFirmwareRollouts([]);
|
||
setSelectedFirmwareRollout(undefined);
|
||
setFirmwareReleaseImpact(undefined);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
void Promise.all([
|
||
platformApi.firmwareSigningKeys(selectedHouseholdID, accessToken, controller.signal),
|
||
platformApi.firmwareReleases(selectedHouseholdID, accessToken, controller.signal),
|
||
platformApi.firmwareRollouts(selectedHouseholdID, accessToken, controller.signal)
|
||
]).then(async ([keyResponse, releaseResponse, rolloutResponse]) => {
|
||
setFirmwareSigningKeys(keyResponse.data.items);
|
||
setFirmwareReleases(releaseResponse.data.items);
|
||
setFirmwareRollouts(rolloutResponse.data.items);
|
||
setRolloutReleaseID((current) => current || releaseResponse.data.items.find((item) => item.status === "READY")?.id || "");
|
||
setRollbackReleaseID((current) => current || releaseResponse.data.items.find((item) => item.status === "READY")?.id || "");
|
||
const first = rolloutResponse.data.items[0];
|
||
if (first) {
|
||
const detailResponse = await platformApi.firmwareRollout(first.id, accessToken, controller.signal);
|
||
setSelectedFirmwareRollout(detailResponse.data);
|
||
} else {
|
||
setSelectedFirmwareRollout(undefined);
|
||
}
|
||
}).catch((cause) => {
|
||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setSubjectError(errorMessage(cause));
|
||
});
|
||
return () => controller.abort();
|
||
}, [accessToken, canManageFirmware, refreshRevision, selectedHouseholdID]);
|
||
|
||
useEffect(() => {
|
||
const currentHouseholdDevices = devices.filter((item) => item.household_id === selectedHouseholdID).map((item) => item.id);
|
||
setRolloutDeviceIDs((current) => current.filter((id) => currentHouseholdDevices.includes(id)).length > 0
|
||
? current.filter((id) => currentHouseholdDevices.includes(id))
|
||
: currentHouseholdDevices);
|
||
}, [devices, selectedHouseholdID]);
|
||
|
||
async function openConversation(conversationID: string) {
|
||
conversationDetailController.current?.abort();
|
||
const controller = new AbortController();
|
||
conversationDetailController.current = controller;
|
||
setDetailLoading(true);
|
||
setSelectedConversationID(conversationID);
|
||
setConversationDetailError(undefined);
|
||
setDetail(undefined);
|
||
setTurns([]);
|
||
setContentSummaries([]);
|
||
setConversationUsage(undefined);
|
||
setConversationMedia(undefined);
|
||
setMediaState("loading");
|
||
setMediaError(undefined);
|
||
setAudioURLs({});
|
||
try {
|
||
const [response, turnResponse, summaryResponse, usageResponse] = await Promise.all([
|
||
platformApi.conversation(conversationID, accessToken, controller.signal),
|
||
platformApi.conversationTurns(conversationID, accessToken, controller.signal),
|
||
platformApi.conversationSummaries(conversationID, accessToken, controller.signal),
|
||
platformApi.conversationUsage(conversationID, accessToken, controller.signal)
|
||
]);
|
||
if (controller.signal.aborted) return;
|
||
setDetail(response.data);
|
||
setTurns(turnResponse.data.items);
|
||
setContentSummaries(summaryResponse.data.items);
|
||
setConversationUsage(usageResponse.data);
|
||
if (response.data.retention_mode === "transcript_and_audio") {
|
||
await loadConversationMedia(conversationID, controller.signal);
|
||
} else {
|
||
setConversationMedia(undefined);
|
||
setMediaState("idle");
|
||
}
|
||
} catch (cause) {
|
||
if (!(cause instanceof DOMException && cause.name === "AbortError") && !controller.signal.aborted) {
|
||
setConversationDetailError(errorMessage(cause));
|
||
setMediaState("idle");
|
||
}
|
||
} finally {
|
||
if (conversationDetailController.current === controller) setDetailLoading(false);
|
||
}
|
||
}
|
||
|
||
async function openStoryConversation(story: Story) {
|
||
selectSection("conversations");
|
||
await openConversation(story.conversation_id);
|
||
}
|
||
|
||
async function loadMoreConversations() {
|
||
if (!selectedSubjectID || !conversationNextCursor || conversationLoadingMore) return;
|
||
setConversationLoadingMore(true);
|
||
setConversationError(undefined);
|
||
try {
|
||
const response = await platformApi.conversations(selectedSubjectID, conversationQuery({
|
||
status: conversationStatus,
|
||
engineMode: conversationEngineMode,
|
||
from: conversationFrom,
|
||
to: conversationTo
|
||
}, conversationNextCursor), accessToken);
|
||
setConversations((current) => {
|
||
const existing = new Set(current.map((item) => item.id));
|
||
return [...current, ...response.data.items.filter((item) => !existing.has(item.id))];
|
||
});
|
||
setConversationNextCursor(response.data.next_cursor);
|
||
} catch (cause) {
|
||
setConversationError(errorMessage(cause));
|
||
} finally {
|
||
setConversationLoadingMore(false);
|
||
}
|
||
}
|
||
|
||
function clearConversationDetail() {
|
||
conversationDetailController.current?.abort();
|
||
setDetail(undefined);
|
||
setSelectedConversationID(undefined);
|
||
setConversationDetailError(undefined);
|
||
setDetailLoading(false);
|
||
setConversationUsage(undefined);
|
||
setTurns([]);
|
||
setContentSummaries([]);
|
||
setConversationMedia(undefined);
|
||
setMediaState("idle");
|
||
setAudioURLs({});
|
||
}
|
||
|
||
function resetConversationFilters() {
|
||
clearConversationDetail();
|
||
setConversations([]);
|
||
setConversationStatus("");
|
||
setConversationEngineMode("");
|
||
setConversationFrom("");
|
||
setConversationTo("");
|
||
setConversationLoadingMore(false);
|
||
}
|
||
|
||
async function loadConversationMedia(conversationID: string, signal?: AbortSignal) {
|
||
setMediaState("loading");
|
||
setMediaError(undefined);
|
||
try {
|
||
const response = await platformApi.conversationMedia(conversationID, accessToken, signal);
|
||
if (signal?.aborted) return;
|
||
setConversationMedia(response.data);
|
||
setMediaState("ready");
|
||
} catch (cause) {
|
||
if (cause instanceof DOMException && cause.name === "AbortError") return;
|
||
setConversationMedia(undefined);
|
||
if (cause instanceof APIRequestError && cause.code === "RECORDING_ACCESS_DENIED") {
|
||
setMediaState("restricted");
|
||
} else {
|
||
setMediaState("error");
|
||
setMediaError(errorMessage(cause));
|
||
}
|
||
}
|
||
}
|
||
|
||
function grantRecordingAccess(input: RecordingGrantInput) {
|
||
if (!selectedSubjectID || !selectedSubject) return;
|
||
const granteeName = members.find((item) => item.user_id === input.grantee_user_id)?.display_name ?? input.grantee_user_id;
|
||
openFamilyControl({ kind: "recording_access_grant", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, granteeName, input, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function revokeRecordingAccess(grant: RecordingAccessGrant) {
|
||
if (!selectedSubjectID || !selectedSubject) return;
|
||
const granteeName = members.find((item) => item.user_id === grant.grantee_user_id)?.display_name ?? grant.grantee_user_id;
|
||
openFamilyControl({ kind: "recording_access_revoke", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, item: grant, granteeName, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function accessRecording(segment: RecordingSegment, action: "PLAY" | "DOWNLOAD", purpose: string) {
|
||
if (!selectedSubjectID || !selectedSubject || !detail) return;
|
||
openFamilyControl({ kind: action === "PLAY" ? "recording_play_address" : "recording_download_address", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, conversationID: detail.id, item: segment, purpose, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function runAction(successMessage: string, action: () => Promise<void>): Promise<boolean> {
|
||
setActionBusy(true); setActionError(undefined); setActionMessage(undefined);
|
||
try {
|
||
await action();
|
||
setActionMessage(successMessage);
|
||
setRefreshRevision((current) => current + 1);
|
||
return true;
|
||
} catch (cause) {
|
||
setActionError(errorMessage(cause));
|
||
return false;
|
||
} finally {
|
||
setActionBusy(false);
|
||
}
|
||
}
|
||
|
||
function createSubject(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!selectedHousehold || !newSubjectName.trim() || !newSubjectBackground.trim() || !newSubjectGoal.trim()) return;
|
||
openFamilyControl({
|
||
kind: "subject_create",
|
||
household: selectedHousehold,
|
||
input: {
|
||
preferred_name: newSubjectName.trim(), language: "zh-CN", timezone: "Asia/Shanghai",
|
||
profile_data: { background: newSubjectBackground.trim(), interests: splitContextList(newSubjectInterests) },
|
||
conversation_preferences: { conversation_goal: newSubjectGoal.trim(), pace: newSubjectPace, preferred_topics: splitContextList(newSubjectTopics), avoided_topics: splitContextList(newSubjectAvoidedTopics) }
|
||
},
|
||
acknowledged: false,
|
||
confirmation: ""
|
||
});
|
||
}
|
||
|
||
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: "" });
|
||
}
|
||
|
||
function createHousehold(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!newHouseholdTenantID || !newHouseholdName.trim()) return;
|
||
const account = accounts.find((item) => item.tenant_id === newHouseholdTenantID);
|
||
if (!account) return;
|
||
openFamilyControl({ kind: "household_create", tenantID: newHouseholdTenantID, tenantName: account.tenant_name, accountDisplayName: account.display_name, householdName: newHouseholdName.trim(), acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function createFact(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!selectedSubject || !newFactContent.trim()) return;
|
||
openFamilyControl({
|
||
kind: "fact_create",
|
||
subjectID: selectedSubject.id,
|
||
subjectName: selectedSubject.preferred_name,
|
||
input: {
|
||
fact_type: newFactType.trim(), content: newFactContent.trim(), source_type: "family_entry",
|
||
source_ref: "family-web-entry", confirmation_status: "pending", sensitivity: newFactSensitivity
|
||
},
|
||
acknowledged: false,
|
||
confirmation: ""
|
||
});
|
||
}
|
||
|
||
async function grantConsent(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!selectedSubject || !policyVersion || !consentConfirmed || selectedConsentAlreadyEffective) return;
|
||
const noticeDigest = await digestHex(consentNotice);
|
||
openFamilyControl({
|
||
kind: "consent_grant",
|
||
subjectID: selectedSubject.id,
|
||
subjectName: selectedSubject.preferred_name,
|
||
input: {
|
||
type: newConsentType as "transcript_storage" | "raw_audio_storage" | "story_extraction" | "personalized_memory" | "family_sharing",
|
||
scope: { scope: "future_conversations" }, policy_version: policyVersion, notice_digest: noticeDigest,
|
||
evidence_ref: "family-web-confirmation"
|
||
},
|
||
notice: consentNotice,
|
||
acknowledged: false,
|
||
confirmation: ""
|
||
});
|
||
}
|
||
|
||
function claimDevice(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!selectedHousehold || !selectedSubjectID || !selectedSubject || !claimCode.trim() || claimCodeIsDeviceID) return false;
|
||
openFamilyControl({ kind: "device_claim", household: selectedHousehold, subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, claimCode: claimCode.trim(), acknowledged: false, confirmation: "" });
|
||
return true;
|
||
}
|
||
|
||
async function confirmFact(fact: SubjectFact) {
|
||
if (!selectedSubjectID || !selectedSubject) return;
|
||
openFamilyControl({ kind: "fact_confirm", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, item: fact, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function withdrawConsent(consent: Consent) {
|
||
if (!selectedSubjectID || !selectedSubject) return;
|
||
openFamilyControl({ kind: "consent_withdraw", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, item: consent, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function updateDeviceVolume(device: ManagedDevice, speakerVolume: number) {
|
||
const twin = twins[device.id];
|
||
if (!twin || !Number.isInteger(speakerVolume) || speakerVolume < 0 || speakerVolume > 100) return;
|
||
const existingVolume = typeof twin.desired_config.volume === "object" && twin.desired_config.volume ? twin.desired_config.volume as Record<string, unknown> : {};
|
||
openFamilyControl({
|
||
kind: "device_twin_update",
|
||
item: device,
|
||
twin,
|
||
input: { desired_config: { volume: { ...existingVolume, speaker: speakerVolume } } },
|
||
previousVolume: deviceSpeakerVolume(twin.desired_config),
|
||
speakerVolume,
|
||
acknowledged: false,
|
||
confirmation: ""
|
||
});
|
||
}
|
||
|
||
function createInvitation(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!selectedHousehold) return;
|
||
openFamilyControl({ kind: "invitation_create", household: selectedHousehold, inviteeHint: inviteeHint.trim(), role: inviteRole, expiresInHours: 168, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function acceptInvitation(event: FormEvent) {
|
||
event.preventDefault();
|
||
const token = acceptInvitationToken.trim();
|
||
if (token.length < 32 || token.length > 128) return;
|
||
setActionBusy(true); setActionError(undefined); setActionMessage(undefined);
|
||
try {
|
||
const preview = (await platformApi.previewInvitation(token, accessToken)).data;
|
||
openFamilyControl({ kind: "invitation_accept", token, preview, accountDisplayName: accounts[0]?.display_name ?? "当前登录账号", acknowledged: false, confirmation: "" });
|
||
} catch (cause) { setActionError(errorMessage(cause)); }
|
||
finally { setActionBusy(false); }
|
||
}
|
||
|
||
async function updateMemberRole(member: HouseholdMember) {
|
||
if (!selectedHousehold || member.is_owner) return;
|
||
openFamilyControl({ kind: "member_role_change", household: selectedHousehold, item: member, targetRole: member.role as "admin" | "editor" | "viewer", acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function removeMember(member: HouseholdMember) {
|
||
if (!selectedHouseholdID || !selectedHousehold) return;
|
||
openFamilyControl({ kind: "member_remove", household: selectedHousehold, item: member, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function revokeInvitation(invitation: MemberInvitation) {
|
||
if (!selectedHousehold) return;
|
||
openFamilyControl({ kind: "invitation_revoke", household: selectedHousehold, item: invitation, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function transferOwnership(member: HouseholdMember) {
|
||
if (!selectedHousehold) return;
|
||
openFamilyControl({ kind: "ownership_transfer", household: selectedHousehold, item: member, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function renameHousehold(name: string): Promise<boolean> {
|
||
if (!selectedHousehold || !name.trim()) return false;
|
||
return runAction("家庭名称已更新。", async () => {
|
||
const response = await platformApi.updateHousehold(selectedHousehold, name.trim(), accessToken);
|
||
setHouseholds((current) => current.map((item) => item.id === response.data.id ? response.data : item));
|
||
});
|
||
}
|
||
|
||
async function openStory(storyID: string) {
|
||
setSubjectLoading(true);
|
||
setSubjectError(undefined);
|
||
try {
|
||
const response = await platformApi.story(storyID, accessToken);
|
||
setSelectedStory(response.data);
|
||
} catch (cause) {
|
||
setSubjectError(errorMessage(cause));
|
||
} finally {
|
||
setSubjectLoading(false);
|
||
}
|
||
}
|
||
|
||
async function reviseStory(story: Story, content: string, reason: string, errorType?: ContentErrorType) {
|
||
await runAction("故事已保存为新的人工修订版本。", async () => {
|
||
const response = await platformApi.reviseStory(story, content, reason, accessToken, errorType);
|
||
setSelectedStory(response.data);
|
||
});
|
||
}
|
||
|
||
async function confirmStory(story: Story, reason: string) {
|
||
await runAction("故事已人工确认,并生成可追溯的长期记忆。", async () => {
|
||
const response = await platformApi.confirmStory(story, reason, accessToken);
|
||
setSelectedStory(response.data.story);
|
||
});
|
||
}
|
||
|
||
async function rejectStory(story: Story, reason: string, errorType?: ContentErrorType) {
|
||
await runAction("故事候选及其候选事实已拒绝。", async () => {
|
||
const response = await platformApi.rejectStory(story, reason, accessToken, errorType);
|
||
setSelectedStory(response.data);
|
||
});
|
||
}
|
||
|
||
async function updateFactCandidate(story: Story, fact: FactCandidate, input: { fact_type?: string; content?: string; sensitivity?: "normal" | "sensitive" | "high"; status?: "CANDIDATE" | "CONFIRMED" | "REJECTED" | "SUPERSEDED"; error_type?: ContentErrorType; reason: string }): Promise<boolean> {
|
||
return runAction(input.status === "CONFIRMED" ? "事实候选已单独确认。" : input.status === "REJECTED" ? "事实候选已单独拒绝。" : input.status === "SUPERSEDED" ? "事实候选已停用。" : "事实候选修改已保存。", async () => {
|
||
const response = await platformApi.updateFactCandidate(story.id, fact, input, accessToken);
|
||
setSelectedStory({ ...story, facts: story.facts.map((item) => item.id === fact.id ? response.data : item) });
|
||
});
|
||
}
|
||
|
||
async function updateMemory(memory: CuratedMemory, input: { content?: string; status?: "ACTIVE" | "DISABLED"; error_type?: ContentErrorType; reason: string }): Promise<boolean> {
|
||
return runAction(input.content ? "记忆已保存为新版本,旧索引已失效。" : input.status === "DISABLED" ? "记忆已停用,不会进入新通话。" : "记忆已重新启用并等待索引。", async () => {
|
||
await platformApi.updateMemory(memory, input, accessToken);
|
||
});
|
||
}
|
||
|
||
async function deleteMemory(memory: CuratedMemory, reason: string): Promise<boolean> {
|
||
return runAction("记忆删除请求已受理,记忆已立即停止用于新通话。", async () => {
|
||
await platformApi.deleteMemory(memory, reason, accessToken);
|
||
});
|
||
}
|
||
|
||
async function createPrivacyAccessRequest() {
|
||
if (!selectedSubjectID || !selectedSubject) return;
|
||
openFamilyControl({ kind: "privacy_access_request", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, categories: privacyCategories(), acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function createPrivacyCorrectionRequest(corrections: { preferred_name?: string; language?: string; timezone?: string }): Promise<boolean> {
|
||
if (!selectedSubjectID) return false;
|
||
return runAction("资料更正申请已完成,后续新通话将使用新的档案版本。", async () => {
|
||
await platformApi.createPrivacyCorrectionRequest(selectedSubjectID, corrections, accessToken);
|
||
setRefreshRevision((current) => current + 1);
|
||
});
|
||
}
|
||
|
||
async function createConsentWithdrawalRequest(consentTypes: Consent["type"][]): Promise<boolean> {
|
||
if (!selectedSubjectID) return false;
|
||
return runAction("所选授权已撤回;依赖授权的活动通话已按安全规则停止。", async () => {
|
||
await platformApi.createConsentWithdrawalRequest(selectedSubjectID, consentTypes, accessToken);
|
||
setRefreshRevision((current) => current + 1);
|
||
});
|
||
}
|
||
|
||
async function createPrivacyDeleteRequest(reason: string, confirmation: string): Promise<boolean> {
|
||
if (!selectedSubjectID || !selectedSubject) return false;
|
||
return runAction("主体删除已完成;个人内容已脱敏,缓存、索引和私有导出已完成对账。", async () => {
|
||
const response = await platformApi.createPrivacyDeleteRequest(selectedSubjectID, reason, confirmation, accessToken);
|
||
const deadline = Date.now() + 30000;
|
||
let current = response.data;
|
||
while (!['COMPLETED', 'PARTIAL_FAILED'].includes(current.status) && Date.now() < deadline) {
|
||
await new Promise((resolve) => window.setTimeout(resolve, 500));
|
||
current = (await platformApi.privacyRequest(current.id, accessToken)).data;
|
||
}
|
||
if (current.status !== 'COMPLETED') throw new Error("删除对账未全部完成,请通过隐私请求编号继续检查子任务状态");
|
||
});
|
||
}
|
||
|
||
async function createExportTask() {
|
||
if (!selectedSubjectID || !selectedSubject) return;
|
||
openFamilyControl({ kind: "privacy_export_request", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, categories: privacyCategories(), acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function downloadExport(task: ExportTask) {
|
||
if (!selectedSubjectID || !selectedSubject) return;
|
||
openFamilyControl({ kind: "privacy_export_download", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, categories: exportTaskCategories(task), item: task, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function requestDeviceDiagnostics(device: ManagedDevice) {
|
||
openFamilyControl({ kind: "device_diagnostics", item: device, presence: presence[device.id], acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function selectFirmwareFile(file?: File) {
|
||
setFirmwareFile(file);
|
||
setFirmwareDigest("");
|
||
if (!file) return;
|
||
if (file.size < 1 || file.size > 268435456) {
|
||
setActionError("固件文件必须在1字节至256MB之间。");
|
||
setFirmwareFile(undefined);
|
||
return;
|
||
}
|
||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||
setFirmwareDigest(Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""));
|
||
}
|
||
|
||
async function registerFirmwareKey(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!selectedHousehold || !firmwareKeyID.trim() || !firmwarePublicKey.trim()) return;
|
||
const publicKey = decodeStandardBase64(firmwarePublicKey.trim());
|
||
if (!publicKey || publicKey.byteLength !== 32) return;
|
||
const fingerprintBytes = await crypto.subtle.digest("SHA-256", publicKey);
|
||
const fingerprint = Array.from(new Uint8Array(fingerprintBytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||
const existing = firmwareSigningKeys.find((item) => item.key_id === firmwareKeyID.trim());
|
||
if (existing && (existing.status !== "ACTIVE" || existing.fingerprint_sha256 !== fingerprint)) {
|
||
setActionError(existing.status === "REVOKED" ? "该密钥编号已经吊销,不能重新激活;请使用新的轮换编号。" : "该密钥编号已绑定另一把公钥,不能静默替换;请核对指纹或使用新的轮换编号。");
|
||
return;
|
||
}
|
||
openFamilyControl({ kind: "firmware_key_register", household: selectedHousehold, keyID: firmwareKeyID.trim(), publicKey: firmwarePublicKey.trim(), fingerprint, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function revokeFirmwareKey(item: FirmwareSigningKey) {
|
||
if (!selectedHousehold || item.status !== "ACTIVE" || item.uploading_releases > 0 || item.running_rollouts > 0) return;
|
||
openFamilyControl({ kind: "firmware_key_revoke", household: selectedHousehold, item, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function publishFirmware(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!selectedHousehold || !firmwareFile || !firmwareDigest || !firmwareModel.trim() || !firmwareVersion.trim() || !firmwareSignature.trim() || !firmwareSigningKeyID.trim()) return;
|
||
const signature = decodeStandardBase64(firmwareSignature.trim());
|
||
if (!signature || signature.byteLength !== 64) return;
|
||
openFamilyControl({
|
||
kind: "firmware_release_publish", household: selectedHousehold, file: firmwareFile,
|
||
input: {
|
||
model: firmwareModel.trim(), hardware_revision: firmwareHardware.trim(), version: firmwareVersion.trim(),
|
||
expected_size: firmwareFile.size, expected_digest: firmwareDigest, signature: firmwareSignature.trim(),
|
||
signing_key_id: firmwareSigningKeyID.trim(), minimum_security_version: firmwareMinimumSecurityVersion.trim(),
|
||
release_notes: firmwareReleaseNotes.trim() || `由管理工作台上传:${firmwareFile.name}`
|
||
},
|
||
acknowledged: false, confirmation: ""
|
||
});
|
||
}
|
||
|
||
function resumeFirmwareRelease(item: FirmwareRelease, file: File, digest: string) {
|
||
if (!selectedHousehold || item.status !== "UPLOADING" || file.size !== Number(item.expected_size) || digest !== item.expected_digest) return;
|
||
openFamilyControl({ kind: "firmware_release_resume", household: selectedHousehold, item, file, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function retireIncompleteFirmware(item: FirmwareRelease) {
|
||
if (!selectedHousehold || !["UPLOADING", "READY", "PAUSED"].includes(item.status)) return;
|
||
openFamilyControl({ kind: "firmware_release_retire", household: selectedHousehold, item, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function pauseFirmwareDistribution(item: FirmwareRelease) {
|
||
if (!selectedHousehold || item.status !== "READY") return;
|
||
const runningRollouts = firmwareRollouts.filter((rollout) => rollout.release_id === item.id && rollout.status === "RUNNING").length;
|
||
openFamilyControl({ kind: "firmware_release_pause_distribution", household: selectedHousehold, item, runningRollouts, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function resumeFirmwareDistribution(item: FirmwareRelease) {
|
||
if (!selectedHousehold || item.status !== "PAUSED") return;
|
||
openFamilyControl({ kind: "firmware_release_resume_distribution", household: selectedHousehold, item, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function startFirmwareRollout(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!selectedHousehold || !rolloutReleaseID || !rolloutName.trim() || rolloutDeviceIDs.length === 0) return;
|
||
const release = firmwareReleases.find((item) => item.id === rolloutReleaseID && item.status === "READY");
|
||
const selectedDevices = devices.filter((item) => item.household_id === selectedHousehold.id && rolloutDeviceIDs.includes(item.id) && item.status === "registered" && item.model === release?.model && (!release?.hardware_revision || item.hardware_revision === release.hardware_revision));
|
||
if (!release || selectedDevices.length !== rolloutDeviceIDs.length) return;
|
||
openFamilyControl({ kind: "firmware_rollout_start", household: selectedHousehold, release, name: rolloutName.trim(), percentage: rolloutPercentage, devices: selectedDevices, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function openFirmwareRollout(rolloutID: string) {
|
||
setDetailLoading(true);
|
||
try {
|
||
const response = await platformApi.firmwareRollout(rolloutID, accessToken);
|
||
setSelectedFirmwareRollout(response.data);
|
||
} catch (cause) {
|
||
setSubjectError(errorMessage(cause));
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
}
|
||
|
||
async function openFirmwareReleaseImpact(releaseID: string) {
|
||
setFirmwareImpactLoading(true);
|
||
try {
|
||
setFirmwareReleaseImpact((await platformApi.firmwareReleaseImpact(releaseID, accessToken)).data);
|
||
} catch (cause) {
|
||
setSubjectError(errorMessage(cause));
|
||
} finally {
|
||
setFirmwareImpactLoading(false);
|
||
}
|
||
}
|
||
|
||
async function refreshFirmwareLifecycle(householdID: string) {
|
||
const [keys, releases, rollouts] = await Promise.all([
|
||
platformApi.firmwareSigningKeys(householdID, accessToken),
|
||
platformApi.firmwareReleases(householdID, accessToken),
|
||
platformApi.firmwareRollouts(householdID, accessToken)
|
||
]);
|
||
setFirmwareSigningKeys(keys.data.items);
|
||
setFirmwareReleases(releases.data.items);
|
||
setFirmwareRollouts(rollouts.data.items);
|
||
if (selectedFirmwareRollout && rollouts.data.items.some((item) => item.id === selectedFirmwareRollout.id)) {
|
||
setSelectedFirmwareRollout((await platformApi.firmwareRollout(selectedFirmwareRollout.id, accessToken)).data);
|
||
}
|
||
if (firmwareReleaseImpact && releases.data.items.some((item) => item.id === firmwareReleaseImpact.release_id)) {
|
||
setFirmwareReleaseImpact((await platformApi.firmwareReleaseImpact(firmwareReleaseImpact.release_id, accessToken)).data);
|
||
}
|
||
}
|
||
|
||
async function pauseFirmwareRollout(rollout: FirmwareRollout) {
|
||
openFamilyControl({ kind: "firmware_pause", item: rollout, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function rollbackFirmwareRollout(rollout: FirmwareRollout) {
|
||
const release = firmwareReleases.find((item) => item.id === rollbackReleaseID);
|
||
if (!release) return;
|
||
openFamilyControl({ kind: "firmware_rollback", item: rollout, release, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function createSharingGrant(input: SharingGrantInput) {
|
||
if (!selectedSubjectID || !selectedSubject) return;
|
||
const granteeName = members.find((item) => item.user_id === input.grantee_user_id)?.display_name ?? input.grantee_user_id;
|
||
const resourceLabel = sharingInputResourceLabel(input, stories, conversations, conversationMedia);
|
||
openFamilyControl({ kind: "sharing_grant_create", subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, granteeName, resourceLabel, input, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
async function revokeSharingGrant(grant: SharingGrant) {
|
||
openFamilyControl({ kind: "sharing_revoke", item: grant, acknowledged: false, confirmation: "" });
|
||
}
|
||
|
||
function openFamilyControl(draft: FamilyControlDraft) {
|
||
setFamilyControlDraft(draft); setActionError(undefined); setActionMessage(undefined);
|
||
window.requestAnimationFrame(() => document.getElementById("family-controlled-operation")?.scrollIntoView({ behavior: "smooth", block: "center" }));
|
||
}
|
||
|
||
function cancelFamilyControl() {
|
||
if (familyControlDraft?.kind === "device_claim") setClaimCode("");
|
||
if (familyControlDraft?.kind === "invitation_accept") setAcceptInvitationToken("");
|
||
if (familyControlDraft?.kind === "consent_grant") setConsentConfirmed(false);
|
||
setFamilyControlDraft(undefined);
|
||
}
|
||
|
||
async function submitFamilyControl(event: FormEvent) {
|
||
event.preventDefault(); const draft = familyControlDraft;
|
||
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 === "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 })); });
|
||
else if (draft.kind === "consent_grant") completed = await runAction("新的长者分项授权已记录,并生成用于后续新通话的不可变授权快照。", async () => { await platformApi.grantConsent(draft.subjectID, draft.input, accessToken); setConsentConfirmed(false); });
|
||
else if (draft.kind === "firmware_key_register") completed = await runAction("固件可信公钥已登记;平台只保存公钥,不接触签名私钥。", async () => { await platformApi.registerFirmwareSigningKey(draft.household.id, { key_id: draft.keyID, public_key_base64: draft.publicKey }, accessToken); setFirmwareSigningKeys((await platformApi.firmwareSigningKeys(draft.household.id, accessToken)).data.items); setFirmwareSigningKeyID(draft.keyID); setFirmwareKeyID(""); setFirmwarePublicKey(""); });
|
||
else if (draft.kind === "firmware_key_revoke") completed = await runAction("固件可信公钥已吊销;现有历史记录保留,但不能再用于新制品或新灰度。", async () => { await platformApi.revokeFirmwareSigningKey(draft.household.id, draft.item.key_id, accessToken); setFirmwareSigningKeys((await platformApi.firmwareSigningKeys(draft.household.id, accessToken)).data.items); });
|
||
else if (draft.kind === "firmware_release_publish") completed = await runAction("固件已上传,并通过对象大小、SHA-256和Ed25519签名校验。", async () => { const prepared = await platformApi.prepareFirmwareRelease(draft.household.id, draft.input, accessToken); if (!prepared.data.upload_url) throw new Error("固件上传地址未生成"); const uploaded = await fetch(prepared.data.upload_url, { method: "PUT", body: draft.file, headers: { "Content-Type": "application/octet-stream" } }); if (!uploaded.ok) throw new Error(`固件对象上传失败(HTTP ${uploaded.status})`); await platformApi.finalizeFirmwareRelease(prepared.data.id, accessToken); setFirmwareReleases((await platformApi.firmwareReleases(draft.household.id, accessToken)).data.items); setFirmwareFile(undefined); setFirmwareDigest(""); setFirmwareVersion(""); setFirmwareSignature(""); setFirmwareReleaseNotes(""); });
|
||
else if (draft.kind === "firmware_release_resume") completed = await runAction("固件续传完成,并重新通过对象大小、SHA-256和Ed25519签名校验。", async () => { const renewed = await platformApi.renewFirmwareUpload(draft.item.id, accessToken); if (!renewed.data.upload_url) throw new Error("固件续传地址未生成"); const uploaded = await fetch(renewed.data.upload_url, { method: "PUT", body: draft.file, headers: { "Content-Type": "application/octet-stream" } }); if (!uploaded.ok) throw new Error(`固件对象续传失败(HTTP ${uploaded.status})`); await platformApi.finalizeFirmwareRelease(draft.item.id, accessToken); setFirmwareReleases((await platformApi.firmwareReleases(draft.household.id, accessToken)).data.items); });
|
||
else if (draft.kind === "firmware_release_pause_distribution") completed = await runAction("固件版本已暂停分发;新灰度和新下载已阻断,关联运行批次已暂停。", async () => { await platformApi.pauseFirmwareRelease(draft.item.id, accessToken); await refreshFirmwareLifecycle(draft.household.id); });
|
||
else if (draft.kind === "firmware_release_resume_distribution") completed = await runAction("固件版本已恢复为可分发;原有已暂停批次不会自动重启。", async () => { await platformApi.resumeFirmwareRelease(draft.item.id, accessToken); await refreshFirmwareLifecycle(draft.household.id); });
|
||
else if (draft.kind === "firmware_release_retire") completed = await runAction(draft.item.status === "UPLOADING" ? "未完成固件版本已退役并尝试清理对象。" : "已验证固件版本已退役;制品和历史记录保留,但不能再分发。", async () => { await platformApi.retireFirmwareRelease(draft.item.id, accessToken); await refreshFirmwareLifecycle(draft.household.id); });
|
||
else if (draft.kind === "firmware_rollout_start") completed = await runAction("固件灰度批次已启动,服务端已为实际命中的兼容设备创建可靠升级命令。", async () => { await platformApi.startFirmwareRollout(draft.household.id, { release_id: draft.release.id, name: draft.name, target_percentage: draft.percentage, device_ids: draft.devices.map((item) => item.id) }, accessToken); setRolloutReleaseID(""); setRolloutName(""); setRolloutPercentage(10); setRolloutDeviceIDs([]); });
|
||
else if (draft.kind === "consent_withdraw") completed = await runAction("授权已撤回;如当前通话使用了该授权,平台将立即安全停止通话并通知设备。", async () => { await platformApi.withdrawConsent(draft.subjectID, draft.item.id, accessToken); });
|
||
else if (draft.kind === "member_remove") completed = await runAction("成员已移出家庭。", async () => { await platformApi.removeMember(draft.household.id, draft.item, accessToken); });
|
||
else if (draft.kind === "ownership_transfer") completed = await runAction("家庭所有权已转移。", async () => { await platformApi.transferOwnership(draft.household, draft.item.id, accessToken); await loadRoot(); });
|
||
else if (draft.kind === "member_role_change") completed = await runAction("成员角色已更新。", async () => { await platformApi.updateMemberRole(draft.household.id, draft.item, draft.targetRole, accessToken); });
|
||
else if (draft.kind === "invitation_create") completed = await runAction("成员邀请已创建;请立即安全传递一次性凭据。", async () => { const response = await platformApi.createInvitation(draft.household.id, { invitee_hint: draft.inviteeHint, role: draft.role, expires_in_hours: draft.expiresInHours }, accessToken); setIssuedInvitationToken(response.data.token); setInviteeHint(""); });
|
||
else if (draft.kind === "invitation_revoke") completed = await runAction("成员邀请已撤销,原一次性凭据不能再被接受。", async () => { await platformApi.revokeInvitation(draft.household.id, draft.item.id, accessToken); });
|
||
else if (draft.kind === "fact_confirm") completed = await runAction("事实已确认,将从后续通话开始生效。", async () => { await platformApi.confirmFact(draft.subjectID, draft.item, accessToken); });
|
||
else if (draft.kind === "recording_access_grant") completed = await runAction(`${draft.input.permission === "PLAY" ? "播放" : "下载"}授权已生效。`, async () => { await platformApi.grantRecordingAccess(draft.subjectID, draft.input, accessToken); setRecordingGrants((await platformApi.recordingAccessGrants(draft.subjectID, accessToken)).data.items); if (detail) await loadConversationMedia(detail.id); });
|
||
else if (draft.kind === "recording_access_revoke") completed = await runAction("录音访问授权已撤销,后续短期地址将不再签发。", async () => { await platformApi.revokeRecordingAccess(draft.item.id, accessToken); setRecordingGrants((await platformApi.recordingAccessGrants(draft.subjectID, accessToken)).data.items); if (detail) await loadConversationMedia(detail.id); });
|
||
else if (draft.kind === "recording_play_address" || draft.kind === "recording_download_address") completed = await runAction(draft.kind === "recording_play_address" ? "已签发本次播放地址,有效期5分钟。" : "已签发本次下载地址,有效期5分钟。", async () => {
|
||
const action = draft.kind === "recording_play_address" ? "PLAY" : "DOWNLOAD";
|
||
const response = await platformApi.createMediaAccess(draft.item.id, action, draft.purpose, accessToken);
|
||
if (action === "PLAY") setAudioURLs((current) => ({ ...current, [draft.item.id]: response.data.access_url }));
|
||
else {
|
||
const link = document.createElement("a"); link.href = response.data.access_url; link.rel = "noopener";
|
||
document.body.appendChild(link); link.click(); link.remove();
|
||
}
|
||
});
|
||
else if (draft.kind === "privacy_access_request") completed = await runAction("数据访问申请已受理,后台正在盘点当前长者的数据范围。", async () => {
|
||
const response = await platformApi.createPrivacyAccessRequest(draft.subjectID, draft.categories, accessToken); const deadline = Date.now() + 15000; let current = response.data;
|
||
while (!["COMPLETED", "PARTIAL_FAILED"].includes(current.status) && Date.now() < deadline) { await new Promise((resolve) => window.setTimeout(resolve, 500)); current = (await platformApi.privacyRequest(current.id, accessToken)).data; }
|
||
});
|
||
else if (draft.kind === "privacy_export_request") completed = await runAction("私有数据导出已生成,可在有效期内申请短期下载地址。", async () => {
|
||
const response = await platformApi.createExportTask(draft.subjectID, draft.categories, accessToken); const deadline = Date.now() + 20000; let current = response.data;
|
||
while (!["READY", "FAILED", "EXPIRED"].includes(current.status) && Date.now() < deadline) { await new Promise((resolve) => window.setTimeout(resolve, 500)); current = (await platformApi.exportTask(current.id, accessToken)).data; }
|
||
if (current.status !== "READY") throw new Error(current.last_error || "私有数据导出未能生成");
|
||
});
|
||
else if (draft.kind === "privacy_export_download") completed = await runAction("已签发本次私有导出下载地址,请在有效期内完成受控下载。", async () => {
|
||
const current = (await platformApi.exportTask(draft.item.id, accessToken)).data;
|
||
if (current.status !== "READY" || !current.download_url) throw new Error("导出文件尚未就绪或已过期");
|
||
const link = document.createElement("a"); link.href = current.download_url; link.rel = "noopener noreferrer"; link.download = `subject-data-${draft.item.id}.json`;
|
||
document.body.appendChild(link); link.click(); link.remove();
|
||
});
|
||
else if (draft.kind === "device_claim") completed = await runAction("设备已绑定到当前长者。", async () => { await platformApi.claimDevice({ claim_code: draft.claimCode, household_id: draft.household.id, subject_id: draft.subjectID }, accessToken); setClaimCode(""); await loadRoot(); });
|
||
else if (draft.kind === "device_diagnostics") completed = await runAction("设备诊断命令已排队,设备在线后将拉取并回传结果。", async () => { await platformApi.createDeviceCommand(draft.item.id, "diagnostics", { checks: ["audio", "network", "storage"] }, accessToken); });
|
||
else if (draft.kind === "firmware_pause") completed = await runAction("固件批次已暂停,未投递命令已取消。", async () => { const response = await platformApi.pauseFirmwareRollout(draft.item.id, accessToken); setSelectedFirmwareRollout(response.data); });
|
||
else if (draft.kind === "firmware_rollback") completed = await runAction("固件回滚批次已创建。", async () => { const response = await platformApi.rollbackFirmwareRollout(draft.item.id, draft.release.id, accessToken); setSelectedFirmwareRollout(response.data); });
|
||
else if (draft.kind === "sharing_grant_create") completed = await runAction(draft.input.login_required ? "定向登录分享已生效。" : "匿名分享已生效;访问口令只显示一次,请安全转交。", async () => { const response = await platformApi.createSharingGrant(draft.input, accessToken); setIssuedShare({ id: response.data.id, token: response.data.access_token }); setSharingGrants((await platformApi.sharingGrants(draft.subjectID, accessToken)).data.items); });
|
||
else completed = await runAction("分享已撤销,后续资源请求将被拒绝。", async () => { await platformApi.revokeSharingGrant(draft.item.id, accessToken); if (selectedSubjectID) setSharingGrants((await platformApi.sharingGrants(selectedSubjectID, accessToken)).data.items); });
|
||
if (completed) setFamilyControlDraft(undefined);
|
||
}
|
||
|
||
return (
|
||
<section className={`workspace workspace--${tab}`} aria-label="家庭业务工作台">
|
||
<div className="workspace__header">
|
||
<div>
|
||
<p className="eyebrow">FAMILY COMPANION</p>
|
||
<h2>{customerSectionTitle(tab)}</h2>
|
||
<p>{customerSectionDescription(tab)}</p>
|
||
</div>
|
||
<div className="workspace__actions">
|
||
{tab === "stories" ? <label className="workspace-story-subject">
|
||
<span>长者</span>
|
||
<select value={selectedSubjectID ?? ""} onChange={(event) => setSelectedSubjectID(event.target.value)}>
|
||
{subjects.map((subject) => <option value={subject.id} key={subject.id}>{subject.preferred_name}</option>)}
|
||
</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}
|
||
{tab === "devices" && canManageDevice ? <button type="button" onClick={() => setDeviceClaimOpen(true)}><PlusOutlined /> 绑定设备</button> : null}
|
||
</div>
|
||
</div>
|
||
|
||
{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}
|
||
|
||
{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}
|
||
{actionError ? <div className="workspace__inline-error" role="alert">{actionError}</div> : null}
|
||
{familyControlDraft ? <div className="workspace-dialog-backdrop" role="presentation"><form id="family-controlled-operation" className={`sensitive-access-request controlled-operation family-controlled-operation workspace-dialog ${familyControlDangerous(familyControlDraft.kind) ? "controlled-operation--danger" : ""}`} aria-label="家庭业务受控操作" onSubmit={(event) => void submitFamilyControl(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">CONTROLLED FAMILY OPERATION</p><h3>{familyControlTitle(familyControlDraft.kind)}</h3></div><span className={`status ${familyControlDangerous(familyControlDraft.kind) ? "danger" : "warning"}`}>{familyControlToneLabel(familyControlDraft.kind)}</span></div><p>{familyControlTarget(familyControlDraft)}</p>{familyControlDraft.kind === "member_remove" || familyControlDraft.kind === "ownership_transfer" ? <label><span>输入成员姓名确认</span><input autoFocus value={familyControlDraft.confirmation} onChange={(event) => setFamilyControlDraft({ ...familyControlDraft, confirmation: event.target.value })} placeholder={`完整输入“${familyControlDraft.item.display_name}”`} /><small>必须与目标成员姓名完全一致。</small></label> : familyControlDraft.kind === "member_role_change" ? <><label><span>调整后的家庭角色</span><select autoFocus value={familyControlDraft.targetRole} onChange={(event) => setFamilyControlDraft({ ...familyControlDraft, targetRole: event.target.value as "admin" | "editor" | "viewer", acknowledged: false })}><option value="admin">管理员</option><option value="editor">编辑者</option><option value="viewer">查看者</option></select><small>{memberRoleImpact(familyControlDraft.targetRole)}</small></label><label className="controlled-operation__confirmation"><input type="checkbox" checked={familyControlDraft.acknowledged} onChange={(event) => setFamilyControlDraft({ ...familyControlDraft, acknowledged: event.target.checked })} />{familyControlAcknowledgement(familyControlDraft)}</label></> : <label className="controlled-operation__confirmation"><input autoFocus type="checkbox" checked={familyControlDraft.acknowledged} onChange={(event) => setFamilyControlDraft({ ...familyControlDraft, acknowledged: event.target.checked })} />{familyControlAcknowledgement(familyControlDraft)}</label>}<div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={actionBusy} onClick={cancelFamilyControl}>取消</button><button type="submit" className={familyControlDangerous(familyControlDraft.kind) ? "button-danger" : ""} disabled={actionBusy || !familyControlReady(familyControlDraft)}>{actionBusy ? "正在处理…" : familyControlSubmitLabel(familyControlDraft.kind)}</button></div></form></div> : null}
|
||
|
||
{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>
|
||
<TabButton active={tab === "subjects"} onClick={() => selectSection("subjects")}>长者与授权</TabButton>
|
||
<TabButton active={tab === "devices"} onClick={() => selectSection("devices")}>设备管理</TabButton>
|
||
{canManageFirmware ? <TabButton active={tab === "firmware"} onClick={() => setTab("firmware")}>固件发布</TabButton> : null}
|
||
<TabButton active={tab === "conversations"} onClick={() => selectSection("conversations")}>通话记录</TabButton>
|
||
<TabButton active={tab === "stories"} onClick={() => selectSection("stories")}>故事与记忆</TabButton>
|
||
{canManageSharing ? <TabButton active={tab === "sharing"} onClick={() => selectSection("sharing")}>资源分享</TabButton> : null}
|
||
{canReadPrivacy ? <TabButton active={tab === "privacy"} onClick={() => selectSection("privacy")}>隐私请求</TabButton> : null}
|
||
<TabButton active={tab === "billing"} onClick={() => selectSection("billing")}>用量与费用</TabButton>
|
||
<TabButton active={tab === "messages"} onClick={() => selectSection("messages")}>消息中心</TabButton>
|
||
</nav> : null}
|
||
|
||
{subjectError ? <div className="workspace__inline-error" role="alert">{subjectError}</div> : null}
|
||
{tab === "home" ? <CustomerHomePanel household={selectedHousehold} subject={selectedSubject} subjects={subjects}
|
||
devices={devices.filter((item) => item.household_id === selectedHousehold.id)} presence={presence}
|
||
conversations={conversations} stories={stories} privacyRequests={privacyRequests}
|
||
onOpen={(section) => selectSection(section)} /> : null}
|
||
{tab === "members" ? <MemberPanel household={selectedHousehold} households={households} members={members} invitations={invitations}
|
||
canManage={canManageMembers} canTransfer={canTransferOwnership} actionBusy={actionBusy}
|
||
onSelectHousehold={setSelectedHouseholdID} onAddHousehold={() => setHouseholdCreateOpen(true)} onAcceptInvitation={() => setInvitationAcceptOpen(true)} onRenameHousehold={renameHousehold}
|
||
inviteeHint={inviteeHint} onInviteeHint={setInviteeHint} inviteRole={inviteRole} onInviteRole={setInviteRole}
|
||
issuedToken={issuedInvitationToken} onDismissToken={() => setIssuedInvitationToken(undefined)} onCreateInvitation={createInvitation}
|
||
onUpdateRole={updateMemberRole} onRemove={removeMember} onRevoke={revokeInvitation} onTransfer={transferOwnership} /> : null}
|
||
{tab === "subjects" ? (
|
||
<div className="subject-management">
|
||
{subjectCreateOpen && canWriteSubject ? <div className="subject-modal-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setSubjectCreateOpen(false); }}>
|
||
<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="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>
|
||
</div>
|
||
<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>
|
||
</div> : null}
|
||
{canWriteSubject ? <details className="subject-management-operations" open={subjectOperationsOpen} onToggle={(event) => setSubjectOperationsOpen(event.currentTarget.open)}>
|
||
<summary><span>资料与授权</span><small>登记稳定事实或调整后续新通话的数据授权</small><button type="button" aria-label="关闭资料与授权" onClick={(event) => { event.preventDefault(); setSubjectOperationsOpen(false); }}><CloseOutlined /></button></summary>
|
||
<div className="subject-management-operations__forms">
|
||
<form className="tool-form" onSubmit={(event) => void createFact(event)}>
|
||
<div><strong>登记稳定事实</strong><span>新登记内容先进入待核对状态。</span></div>
|
||
<label><span>事实类型</span><select value={newFactType} onChange={(event) => { const factType = event.target.value; setNewFactType(factType); if (factType === "health_context" && newFactSensitivity === "normal") setNewFactSensitivity("sensitive"); }}><option value="family_note">家庭信息</option><option value="preference">个人偏好</option><option value="life_event">人生事件</option><option value="health_context">健康背景</option></select></label>
|
||
<label><span>敏感等级</span><select value={newFactSensitivity} onChange={(event) => setNewFactSensitivity(event.target.value as "normal" | "sensitive" | "high")}><option value="normal">普通</option><option value="sensitive">敏感</option><option value="high">高度敏感</option></select></label>
|
||
<label className="tool-form__wide"><span>事实内容</span><input value={newFactContent} maxLength={4000} onChange={(event) => setNewFactContent(event.target.value)} placeholder="输入经过家属初步确认的信息" required /><small>来源固定登记为家庭成员录入,创建后保持待核对状态。</small></label>
|
||
<button type="submit" disabled={actionBusy || !selectedSubjectID || !newFactContent.trim()}>登记候选</button>
|
||
</form>
|
||
<form className="tool-form" onSubmit={(event) => void grantConsent(event)}>
|
||
<div><strong>新增分项授权</strong><span>授权逐项记录,适用于后续新通话。</span></div>
|
||
<label><span>授权项目</span><select value={newConsentType} onChange={(event) => { setNewConsentType(event.target.value); setConsentConfirmed(false); }}><option value="transcript_storage">字幕保存</option><option value="raw_audio_storage">原录音保存</option><option value="story_extraction">故事整理</option><option value="personalized_memory">个性化记忆</option><option value="family_sharing">家庭分享</option></select><small>{selectedConsentAlreadyEffective ? "当前长者已有同类有效授权,请先查看或按正式流程撤回。" : consentImpactLabel(newConsentType)}</small></label>
|
||
<label className="consent-confirm"><input type="checkbox" checked={consentConfirmed} onChange={(event) => setConsentConfirmed(event.target.checked)} /><span>{consentNotice}</span></label>
|
||
<button type="submit" disabled={actionBusy || !selectedSubjectID || !policyVersion || !consentConfirmed || selectedConsentAlreadyEffective}>核对并授权</button>
|
||
</form>
|
||
</div>
|
||
</details> : <p className="workspace__readonly-note">当前家庭角色为只读,创建档案、登记事实和管理授权操作已禁用。</p>}
|
||
<SubjectPanel subjects={subjects} selectedID={selectedSubjectID} onSelect={setSelectedSubjectID}
|
||
subject={selectedSubject} facts={facts} consents={consents} loading={subjectLoading} canWrite={canWriteSubject}
|
||
actionBusy={actionBusy} onAdd={() => setSubjectCreateOpen(true)} onUpdateSubject={updateSubjectProfile} onConfirmFact={confirmFact} onWithdrawConsent={withdrawConsent} />
|
||
</div>
|
||
) : null}
|
||
{tab === "devices" ? (
|
||
<>
|
||
{deviceClaimOpen && canManageDevice ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDeviceClaimOpen(false); }}><form className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="claim-device-title" onSubmit={(event) => { if (claimDevice(event)) setDeviceClaimOpen(false); }}><header><div><h3 id="claim-device-title">绑定新设备</h3><p>绑定码使用后立即失效,设备将关联到所选长者。</p></div><button type="button" aria-label="关闭" onClick={() => setDeviceClaimOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>设备绑定到长者</span><select value={selectedSubjectID ?? ""} onChange={(event) => setSelectedSubjectID(event.target.value)} required>{subjects.map((subject) => <option value={subject.id} key={subject.id}>{subject.preferred_name}</option>)}</select></label><label><span>一次性设备绑定码</span><input autoFocus value={claimCode} minLength={16} maxLength={128} autoComplete="off" aria-invalid={claimCodeIsDeviceID || undefined} onChange={(event) => setClaimCode(event.target.value)} placeholder="输入设备屏幕或维护端生成的绑定码" required /><small role={claimCodeIsDeviceID ? "alert" : undefined}>{claimCodeIsDeviceID ? "这看起来是设备编号,不是绑定码。请从设备屏幕或维护端设备管理获取一次性绑定码。" : "请勿填写设备编号;绑定码由设备屏幕显示,或由维护端为未绑定设备生成。"}</small></label></div><footer><button type="button" className="button-secondary" onClick={() => setDeviceClaimOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !selectedSubjectID || claimCode.trim().length < 16 || claimCode.trim().length > 128 || claimCodeIsDeviceID}>下一步确认</button></footer></form></div> : null}
|
||
<DevicePanel devices={devices.filter((item) => item.household_id === selectedHousehold.id)} subjects={subjects} presence={presence} twins={twins} canManage={canManageDevice} actionBusy={actionBusy} accessToken={accessToken} onSetVolume={updateDeviceVolume} onDiagnostics={requestDeviceDiagnostics} onBindingChanged={async () => { await loadRoot(); }} />
|
||
</>
|
||
) : null}
|
||
{tab === "firmware" && canManageFirmware ? <FirmwarePanel
|
||
signingKeys={firmwareSigningKeys} releases={firmwareReleases} rollouts={firmwareRollouts} selectedRollout={selectedFirmwareRollout} releaseImpact={firmwareReleaseImpact}
|
||
devices={devices.filter((item) => item.household_id === selectedHousehold.id)} actionBusy={actionBusy} detailLoading={detailLoading}
|
||
impactLoading={firmwareImpactLoading}
|
||
keyID={firmwareKeyID} onKeyID={setFirmwareKeyID} publicKey={firmwarePublicKey} onPublicKey={setFirmwarePublicKey}
|
||
model={firmwareModel} onModel={setFirmwareModel} hardware={firmwareHardware} onHardware={setFirmwareHardware}
|
||
version={firmwareVersion} onVersion={setFirmwareVersion} signature={firmwareSignature} onSignature={setFirmwareSignature}
|
||
signingKeyID={firmwareSigningKeyID} onSigningKeyID={setFirmwareSigningKeyID} file={firmwareFile} digest={firmwareDigest} onFile={selectFirmwareFile}
|
||
minimumSecurityVersion={firmwareMinimumSecurityVersion} onMinimumSecurityVersion={setFirmwareMinimumSecurityVersion}
|
||
releaseNotes={firmwareReleaseNotes} onReleaseNotes={setFirmwareReleaseNotes}
|
||
rolloutReleaseID={rolloutReleaseID} onRolloutReleaseID={setRolloutReleaseID} rolloutName={rolloutName} onRolloutName={setRolloutName}
|
||
percentage={rolloutPercentage} onPercentage={setRolloutPercentage} rolloutDeviceIDs={rolloutDeviceIDs} onRolloutDeviceIDs={setRolloutDeviceIDs}
|
||
rollbackReleaseID={rollbackReleaseID} onRollbackReleaseID={setRollbackReleaseID}
|
||
onRegisterKey={registerFirmwareKey} onPublish={publishFirmware} onStartRollout={startFirmwareRollout}
|
||
onRevokeKey={revokeFirmwareKey}
|
||
onResumeRelease={resumeFirmwareRelease} onPauseRelease={pauseFirmwareDistribution} onResumeDistribution={resumeFirmwareDistribution} onRetireRelease={retireIncompleteFirmware}
|
||
onOpenImpact={openFirmwareReleaseImpact}
|
||
onOpenRollout={openFirmwareRollout} onPause={pauseFirmwareRollout} onRollback={rollbackFirmwareRollout}
|
||
/> : null}
|
||
{tab === "conversations" ? (
|
||
<ConversationPanel subjects={subjects} selectedID={selectedSubjectID} onSelect={setSelectedSubjectID}
|
||
conversations={conversations} 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}
|
||
detailError={conversationDetailError} selectedConversationID={selectedConversationID}
|
||
status={conversationStatus} onStatus={(value) => { clearConversationDetail(); setConversations([]); setConversationStatus(value); }}
|
||
engineMode={conversationEngineMode} onEngineMode={(value) => { clearConversationDetail(); setConversations([]); setConversationEngineMode(value); }}
|
||
from={conversationFrom} onFrom={(value) => { clearConversationDetail(); setConversations([]); setConversationFrom(value); }}
|
||
to={conversationTo} onTo={(value) => { clearConversationDetail(); setConversations([]); setConversationTo(value); }}
|
||
hasMore={Boolean(conversationNextCursor)} onResetFilters={resetConversationFilters} onLoadMore={loadMoreConversations}
|
||
onOpen={openConversation} onGrant={grantRecordingAccess}
|
||
onRevoke={revokeRecordingAccess} onAccess={accessRecording} />
|
||
) : null}
|
||
{tab === "stories" ? <StoryPanel selectedID={selectedSubjectID}
|
||
stories={stories} memories={memories} selected={selectedStory} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory}
|
||
onOpen={openStory} onOpenConversation={openStoryConversation} onRevise={reviseStory} onConfirm={confirmStory} onReject={rejectStory} onUpdateFact={updateFactCandidate} onUpdateMemory={updateMemory} onDeleteMemory={deleteMemory} /> : null}
|
||
{tab === "sharing" && canManageSharing ? <SharingPanel subjects={subjects} selectedID={selectedSubjectID} onSelect={setSelectedSubjectID}
|
||
members={members} stories={stories} conversations={conversations} media={conversationMedia} grants={sharingGrants}
|
||
issued={issuedShare} onDismissIssued={() => setIssuedShare(undefined)} actionBusy={actionBusy}
|
||
onCreate={createSharingGrant} onRevoke={revokeSharingGrant} /> : null}
|
||
{tab === "privacy" && canReadPrivacy ? <PrivacyPanel subjects={subjects} selectedID={selectedSubjectID} onSelect={setSelectedSubjectID}
|
||
requests={privacyRequests} exports={exportTasks} consents={consents} loading={subjectLoading} canManage={canManagePrivacy} actionBusy={actionBusy}
|
||
onCreateAccess={createPrivacyAccessRequest} onCreateCorrection={createPrivacyCorrectionRequest} onWithdrawConsents={createConsentWithdrawalRequest} onCreateDelete={createPrivacyDeleteRequest} onCreateExport={createExportTask} onDownloadExport={downloadExport} /> : null}
|
||
{tab === "privacy" && !canReadPrivacy ? <StatePanel title="当前角色不能查看隐私请求" detail="家庭查看者没有隐私请求读取权限,请联系家庭所有者或管理员。" /> : null}
|
||
{tab === "sharing" && !canManageSharing ? <StatePanel title="当前角色不能管理资源分享" detail="仅家庭所有者或管理员可以创建、撤销故事、文字和录音分享。" /> : null}
|
||
{tab === "billing" ? <FamilyUsageOverview accessToken={accessToken} household={selectedHousehold} devices={devices.filter((item) => item.household_id === selectedHousehold.id)} /> : null}
|
||
{tab === "messages" ? <CustomerMessagePanel notices={eventNotices} devices={devices.filter((item) => item.household_id === selectedHousehold.id)} presence={presence} stories={stories} privacyRequests={privacyRequests} eventState={eventStreamState} /> : null}
|
||
</>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function CustomerHomePanel({ household, subject, subjects, devices, presence, conversations, stories, privacyRequests, onOpen }: {
|
||
household: Household;
|
||
subject?: Subject;
|
||
subjects: Subject[];
|
||
devices: ManagedDevice[];
|
||
presence: Record<string, DevicePresence>;
|
||
conversations: ConversationSummary[];
|
||
stories: Story[];
|
||
privacyRequests: PrivacyRequest[];
|
||
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>
|
||
</div>;
|
||
}
|
||
|
||
function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequests, eventState }: {
|
||
notices: Array<{ id: string; type: string; resourceType: string; status: string; occurredAt: string }>;
|
||
devices: ManagedDevice[];
|
||
presence: Record<string, DevicePresence>;
|
||
stories: Story[];
|
||
privacyRequests: PrivacyRequest[];
|
||
eventState: EventStreamState;
|
||
}) {
|
||
const offline = devices.filter((item) => presence[item.id] && !presence[item.id].online);
|
||
const pendingStories = stories.filter((item) => item.status === "CANDIDATE" || item.status === "IN_REVIEW");
|
||
const activePrivacy = privacyRequests.filter((item) => !["COMPLETED", "REJECTED", "FAILED"].includes(item.status));
|
||
const derived = [
|
||
...offline.map((item) => ({ id: `offline-${item.id}`, title: "设备当前离线", detail: `${item.subject_name} · ${item.model}`, tone: "warning" })),
|
||
...pendingStories.map((item) => ({ id: `story-${item.id}`, title: "故事等待家庭确认", detail: item.title, tone: "info" })),
|
||
...activePrivacy.map((item) => ({ id: `privacy-${item.id}`, title: "隐私请求处理中", detail: `${item.request_type} · ${item.status}`, tone: "info" }))
|
||
];
|
||
return <section className="customer-message-panel">
|
||
<div className="panel-title"><div><p className="eyebrow">MESSAGE CENTER</p><h3>消息中心</h3><p>这里只展示状态提醒,不在通知中携带转写、录音、故事正文或隐私原因。</p></div><span className={`event-stream-status event-stream-status--${eventState}`}>{eventState === "connected" ? "实时连接" : eventState === "reconnecting" ? "重新连接" : "正在连接"}</span></div>
|
||
{derived.length ? <div className="message-list">{derived.map((item) => <article key={item.id} className={`message-item message-item--${item.tone}`}><span aria-hidden="true" /><div><strong>{item.title}</strong><p>{item.detail}</p></div></article>)}</div> : <p className="workspace__readonly-note">当前没有需要处理的家庭提醒。</p>}
|
||
<div className="message-timeline"><h4>本次登录收到的实时事件</h4>{notices.length ? notices.map((notice) => <article key={notice.id}><time>{formatDate(notice.occurredAt)}</time><div><strong>{eventTypeLabel(notice.type)}</strong><p>{resourceTypeLabel(notice.resourceType)} · {notice.status}</p></div></article>) : <p>暂未收到新的状态事件。服务端事件到达后会自动出现在这里。</p>}</div>
|
||
</section>;
|
||
}
|
||
|
||
function customerSectionTitle(section: Tab) {
|
||
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: "维护长者档案、稳定事实和分项授权。",
|
||
devices: "绑定陪伴设备,查看在线状态并调整安全配置。",
|
||
firmware: "管理设备固件发布。",
|
||
conversations: "查看通话进展、文字内容、录音授权和单次费用。",
|
||
stories: "核对从通话中整理的故事和长期记忆。",
|
||
sharing: "按资源和权限范围向指定家庭成员分享内容。",
|
||
privacy: "提交访问、导出或删除申请并跟踪处理进度。",
|
||
billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。",
|
||
messages: "集中查看设备、通话、故事和隐私请求的状态提醒。"
|
||
} as Record<Tab, string>)[section];
|
||
}
|
||
|
||
function eventTypeLabel(type: string) {
|
||
return ({ "device.presence.changed": "设备在线状态变化", "conversation.started": "通话已开始", "conversation.completed": "通话已完成", "conversation.failed": "通话启动失败", "story.created": "生成新的故事候选", "story.revised": "故事内容已更新", "media.upload.completed": "通话媒体上传完成", "export.ready": "隐私导出已就绪", "privacy_request.updated": "隐私请求状态变化", "device.command.updated": "设备指令状态变化" } as Record<string, string>)[type] ?? "业务状态已更新";
|
||
}
|
||
|
||
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 }: {
|
||
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;
|
||
onUpdateRole: (member: HouseholdMember) => Promise<void>;
|
||
onRemove: (member: HouseholdMember) => Promise<void>; onRevoke: (invitation: MemberInvitation) => Promise<void>; onTransfer: (member: HouseholdMember) => Promise<void>;
|
||
onSelectHousehold: (id: string) => void; onAddHousehold: () => void; onAcceptInvitation: () => void; onRenameHousehold: (name: string) => Promise<boolean>;
|
||
}) {
|
||
const [inviteOpen, setInviteOpen] = useState(false);
|
||
const [editOpen, setEditOpen] = useState(false);
|
||
const [householdName, setHouseholdName] = useState(household.name);
|
||
const [showAllActivity, setShowAllActivity] = useState(false);
|
||
const pendingInvitations = invitations.filter((item) => item.status === "pending");
|
||
const activityItems = invitations.length
|
||
? invitations.map((item) => ({ id: item.id, title: item.invitee_hint || "家庭成员邀请", detail: `${invitationStatusLabel(item.status)} · ${formatDate(item.expires_at)}`, invitation: item }))
|
||
: members.map((item) => ({ id: item.id, title: `${item.display_name}加入了家庭`, detail: formatDate(item.joined_at), invitation: undefined }));
|
||
useEffect(() => setHouseholdName(household.name), [household.id, household.name]);
|
||
return <div className="family-overview">
|
||
<header className="family-overview__head">
|
||
<div className="family-overview__identity">
|
||
<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>
|
||
<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>
|
||
</div>
|
||
<p>{members.length} 位家庭成员 · 当前角色 {roleLabel(household.role)}</p>
|
||
</div>
|
||
<button type="button" className="button-secondary family-accept-button" onClick={onAcceptInvitation}>接受邀请</button>
|
||
</header>
|
||
<div className="family-overview__layout">
|
||
<section className="family-members-panel"><div className="section-title"><h4>家庭成员({members.length})</h4></div><div className="family-member-grid">
|
||
{members.map((member) => <article className={member.is_owner ? "family-member-card family-member-card--owner" : "family-member-card"} key={member.id}>
|
||
<span className="family-member-card__avatar"><UserOutlined />{member.is_owner ? <small><SafetyCertificateOutlined /></small> : null}</span>
|
||
<div className="family-member-card__content"><strong>{member.display_name}</strong><span className={member.is_owner ? "family-role family-role--owner" : "family-role"}>{member.is_owner ? "管理员(家长)" : roleLabel(member.role)}</span></div>
|
||
{!member.is_owner && (canManage || canTransfer) ? <details className="family-member-menu"><summary aria-label={`管理${member.display_name}`}><MoreOutlined /></summary><div>{canManage ? <button type="button" disabled={actionBusy} onClick={() => void onUpdateRole(member)}>调整角色</button> : null}{canTransfer ? <button type="button" disabled={actionBusy} onClick={() => void onTransfer(member)}>设为所有者</button> : null}{canManage ? <button type="button" className="danger" disabled={actionBusy} onClick={() => void onRemove(member)}>移出家庭</button> : null}</div></details> : null}
|
||
</article>)}
|
||
{canManage ? <button type="button" className="family-member-card family-member-card--invite" onClick={() => setInviteOpen(true)}><span><UserAddOutlined /></span><strong>邀请新成员</strong></button> : null}
|
||
</div></section>
|
||
<aside className="family-overview__aside">
|
||
<section className="family-home-card"><div className="family-home-card__image"><div><HomeOutlined /><span><strong>主要住所</strong><small>{household.name}</small></span></div></div><footer><span>主家庭</span><button type="button" disabled={!canManage} onClick={() => setEditOpen(true)}>管理设置</button></footer></section>
|
||
<section className="family-activity-card"><div className="section-title"><h4>最近动态</h4>{pendingInvitations.length ? <span>{pendingInvitations.length} 待处理</span> : null}</div>{activityItems.length ? activityItems.slice(0, showAllActivity ? activityItems.length : 2).map((activity) => <article key={activity.id}><ClockCircleOutlined /><div><strong>{activity.title}</strong><p>{activity.detail}</p></div>{activity.invitation?.status === "pending" && canManage ? <button type="button" className="button-small button-danger" disabled={actionBusy} onClick={() => void onRevoke(activity.invitation!)}>撤销</button> : null}</article>) : <p className="empty-copy">暂无新的家庭动态。</p>}{activityItems.length > 2 ? <button type="button" className="family-activity-card__more" onClick={() => setShowAllActivity((current) => !current)}>{showAllActivity ? "收起" : "查看全部"}</button> : null}</section>
|
||
</aside>
|
||
</div>
|
||
|
||
{editOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setEditOpen(false); }}><form className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="edit-household-title" onSubmit={async (event) => { event.preventDefault(); if (await onRenameHousehold(householdName)) setEditOpen(false); }}><header><div><h3 id="edit-household-title">编辑家庭</h3><p>修改家庭名称不会影响成员关系和已有数据。</p></div><button type="button" aria-label="关闭" onClick={() => setEditOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>家庭名称</span><input autoFocus value={householdName} maxLength={200} onChange={(event) => setHouseholdName(event.target.value)} required /></label></div><footer><button type="button" className="button-secondary" onClick={() => setEditOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !householdName.trim() || householdName.trim() === household.name}>保存</button></footer></form></div> : null}
|
||
{inviteOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setInviteOpen(false); }}><form className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="invite-member-title" onSubmit={(event) => { void onCreateInvitation(event); setInviteOpen(false); }}><header><div><h3 id="invite-member-title">邀请家庭成员</h3><p>邀请凭据只显示一次,默认七天有效。</p></div><button type="button" aria-label="关闭" onClick={() => setInviteOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>接收人提示</span><input autoFocus value={inviteeHint} onChange={(event) => onInviteeHint(event.target.value)} placeholder="姓名或脱敏联系方式" /></label><label><span>加入角色</span><select value={inviteRole} onChange={(event) => onInviteRole(event.target.value as "admin" | "editor" | "viewer")}><option value="admin">管理员</option><option value="editor">编辑者</option><option value="viewer">查看者</option></select></label></div><footer><button type="button" className="button-secondary" onClick={() => setInviteOpen(false)}>取消</button><button type="submit" disabled={actionBusy}>下一步确认</button></footer></form></div> : null}
|
||
{issuedToken ? <div className="workspace-dialog-backdrop" role="presentation"><div className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="invitation-secret-title"><header><div><h3 id="invitation-secret-title">邀请凭据已生成</h3><p>刷新或关闭后不再显示,服务端只保存摘要。</p></div></header><div className="workspace-dialog__body"><label><span>一次性邀请凭据</span><input readOnly value={issuedToken} /></label></div><footer><button type="button" className="button-secondary" onClick={() => void navigator.clipboard.writeText(issuedToken)}>复制凭据</button><button type="button" onClick={onDismissToken}>我已安全保存</button></footer></div></div> : null}
|
||
</div>;
|
||
}
|
||
|
||
function SubjectPanel({ subjects, selectedID, onSelect, subject, facts, consents, loading, canWrite, actionBusy, onAdd, onUpdateSubject, onConfirmFact, onWithdrawConsent }: {
|
||
subjects: Subject[]; selectedID?: string; onSelect: (id: string) => void; subject?: Subject;
|
||
facts: SubjectFact[]; consents: Consent[]; loading: boolean; canWrite: boolean; actionBusy: boolean;
|
||
onAdd: () => void;
|
||
onUpdateSubject: (subject: Subject, input: UpdateSubjectRequest) => void;
|
||
onConfirmFact: (fact: SubjectFact) => Promise<void>; onWithdrawConsent: (consent: Consent) => Promise<void>;
|
||
}) {
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
const openSubject = (id: string) => { onSelect(id); setDetailOpen(true); };
|
||
return <div className="workspace__split subject-card-layout">
|
||
<div className="subject-card-grid" aria-label="长者档案列表">
|
||
{subjects.map((item, index) => {
|
||
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__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>
|
||
<span className="subject-profile-card__footer"><small>{interests.length ? `${interests.length} 项兴趣已记录` : "等待完善资料"}</small><span><EditOutlined /> 完善资料</span></span>
|
||
</button>;
|
||
})}
|
||
{canWrite ? <button type="button" className="subject-profile-card subject-profile-card--add" onClick={onAdd}><span><UserAddOutlined /></span><strong>添加新长者</strong><small>创建新的长者档案与交流偏好</small></button> : null}
|
||
{subjects.length === 0 && !canWrite ? <p className="empty-copy">这个家庭还没有长者档案。</p> : null}
|
||
</div>
|
||
{detailOpen ? <div className="subject-modal-backdrop subject-detail-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}>
|
||
<section className="subject-detail-modal" role="dialog" aria-modal="true" aria-labelledby="subject-detail-title">
|
||
{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>
|
||
<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>
|
||
<section className="subject-record-card"><div className="subject-section-heading"><span className="subject-section-icon subject-section-icon--safe"><SafetyCertificateOutlined /></span><div><h4>分项授权</h4><p>每项能力独立授权,保护长者隐私</p></div><strong>{consents.filter((item) => item.effective).length}/5</strong></div>{consents.length ? consents.map((consent) => <article className="subject-data-row" key={consent.id}><div><strong>{consentLabel(consent.type)}</strong><p>{consent.policy_version} · {formatDate(consent.granted_at)}</p></div><div className="row-actions"><span className={consent.effective ? "tag tag--confirmed" : "tag tag--rejected"}>{consent.effective ? "有效" : "已失效"}</span>{canWrite && consent.effective ? <button type="button" className="button-small button-danger" disabled={actionBusy} onClick={() => void onWithdrawConsent(consent)}>撤回</button> : null}</div></article>) : <p className="empty-copy">尚未记录分项授权。</p>}</section>
|
||
</div>
|
||
</div>
|
||
</> : <StatePanel title="请选择一位长者" detail="选择后查看档案、稳定事实和分项授权。" />}
|
||
</section>
|
||
</div> : null}
|
||
</div>;
|
||
}
|
||
|
||
function SubjectProfileEditor({ subject, canWrite, actionBusy, onSave }: {
|
||
subject: Subject; canWrite: boolean; actionBusy: boolean;
|
||
onSave: (subject: Subject, input: UpdateSubjectRequest) => void;
|
||
}) {
|
||
const [name, setName] = useState(subject.preferred_name);
|
||
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"));
|
||
useEffect(() => {
|
||
setName(subject.preferred_name);
|
||
setBackground(profileString(subject.profile_data, "background"));
|
||
setInterests(profileList(subject.profile_data, "interests"));
|
||
setGoal(profileString(subject.conversation_preferences, "conversation_goal"));
|
||
setPace(profilePace(subject.conversation_preferences));
|
||
setTopics(profileList(subject.conversation_preferences, "preferred_topics"));
|
||
setAvoidedTopics(profileList(subject.conversation_preferences, "avoided_topics"));
|
||
}, [subject]);
|
||
function submit(event: FormEvent) {
|
||
event.preventDefault();
|
||
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 },
|
||
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]);
|
||
}
|
||
return <form className="profile-editor subject-profile-editor" onSubmit={(event) => void submit(event)}>
|
||
<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="删除兴趣标签" />
|
||
</section>
|
||
<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>
|
||
<SubjectTagEditor items={avoidedTopics} onChange={setAvoidedTopics} disabled={!canWrite} placeholder="添加避讳话题(按回车确认)" emptyText="尚未添加避讳话题" removeLabel="删除避讳话题" tone="avoid" />
|
||
<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-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 /><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}
|
||
</form>;
|
||
}
|
||
|
||
function SubjectTagEditor({ items, onChange, disabled, placeholder, emptyText, removeLabel, tone = "interest" }: {
|
||
items: string[];
|
||
onChange: (items: string[]) => void;
|
||
disabled: boolean;
|
||
placeholder: string;
|
||
emptyText: string;
|
||
removeLabel: string;
|
||
tone?: "interest" | "topic" | "avoid";
|
||
}) {
|
||
const [draft, setDraft] = useState("");
|
||
const add = () => {
|
||
const item = draft.trim().slice(0, 50);
|
||
if (!item || items.length >= 8 || items.includes(item)) return;
|
||
onChange([...items, item]);
|
||
setDraft("");
|
||
};
|
||
return <div className={`subject-tag-editor subject-tag-editor--${tone}`}>
|
||
<div className="subject-tag-editor__chips">
|
||
{items.map((item) => <span key={item}><strong>{item}</strong>{disabled ? null : <button type="button" aria-label={`${removeLabel}:${item}`} onClick={() => onChange(items.filter((candidate) => candidate !== item))}><CloseOutlined /></button>}</span>)}
|
||
{items.length === 0 ? <small>{emptyText}</small> : null}
|
||
</div>
|
||
{disabled ? null : <div className="subject-tag-editor__input"><input value={draft} maxLength={50} placeholder={items.length >= 8 ? "最多添加 8 项" : placeholder} disabled={items.length >= 8} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); add(); } }} /><button type="button" aria-label="添加" disabled={!draft.trim() || items.length >= 8 || items.includes(draft.trim())} onClick={add}><PlusOutlined /></button></div>}
|
||
</div>;
|
||
}
|
||
|
||
type FirmwarePanelProps = {
|
||
signingKeys: FirmwareSigningKey[]; releases: FirmwareRelease[]; rollouts: FirmwareRollout[]; selectedRollout?: FirmwareRollout; releaseImpact?: FirmwareReleaseImpact; devices: ManagedDevice[];
|
||
actionBusy: boolean; detailLoading: boolean; impactLoading: boolean; keyID: string; onKeyID: (value: string) => void; publicKey: string; onPublicKey: (value: string) => void;
|
||
model: string; onModel: (value: string) => void; hardware: string; onHardware: (value: string) => void; version: string; onVersion: (value: string) => void;
|
||
signature: string; onSignature: (value: string) => void; signingKeyID: string; onSigningKeyID: (value: string) => void; file?: File; digest: string; onFile: (file?: File) => Promise<void>;
|
||
minimumSecurityVersion: string; onMinimumSecurityVersion: (value: string) => void; releaseNotes: string; onReleaseNotes: (value: string) => void;
|
||
rolloutReleaseID: string; onRolloutReleaseID: (value: string) => void; rolloutName: string; onRolloutName: (value: string) => void;
|
||
percentage: number; onPercentage: (value: number) => void; rolloutDeviceIDs: string[]; onRolloutDeviceIDs: (value: string[]) => void;
|
||
rollbackReleaseID: string; onRollbackReleaseID: (value: string) => void;
|
||
onRegisterKey: (event: FormEvent) => Promise<void>; onPublish: (event: FormEvent) => void; onStartRollout: (event: FormEvent) => void;
|
||
onRevokeKey: (item: FirmwareSigningKey) => void;
|
||
onResumeRelease: (release: FirmwareRelease, file: File, digest: string) => void; onPauseRelease: (release: FirmwareRelease) => void;
|
||
onResumeDistribution: (release: FirmwareRelease) => void; onRetireRelease: (release: FirmwareRelease) => void;
|
||
onOpenImpact: (releaseID: string) => Promise<void>;
|
||
onOpenRollout: (id: string) => Promise<void>; onPause: (rollout: FirmwareRollout) => Promise<void>; onRollback: (rollout: FirmwareRollout) => Promise<void>;
|
||
};
|
||
|
||
function FirmwarePanel(props: FirmwarePanelProps) {
|
||
const [recoveryReleaseID, setRecoveryReleaseID] = useState("");
|
||
const [recoveryFile, setRecoveryFile] = useState<File>();
|
||
const [recoveryDigest, setRecoveryDigest] = useState("");
|
||
const readyReleases = props.releases.filter((item) => item.status === "READY");
|
||
const recoveryRelease = props.releases.find((item) => item.id === recoveryReleaseID && item.status === "UPLOADING");
|
||
const recoveryMatches = !!recoveryRelease && !!recoveryFile && recoveryFile.size === Number(recoveryRelease.expected_size) && recoveryDigest === recoveryRelease.expected_digest;
|
||
const publicKeyValid = decodeStandardBase64(props.publicKey.trim())?.byteLength === 32;
|
||
const existingSigningKey = props.signingKeys.find((item) => item.key_id === props.keyID.trim());
|
||
const signatureValid = decodeStandardBase64(props.signature.trim())?.byteLength === 64;
|
||
const publishMetadataValid = !!props.model.trim() && props.model.trim().length <= 120 && props.hardware.trim().length <= 80 && !!props.version.trim() && props.version.trim().length <= 80 && !!props.signingKeyID.trim() && props.signingKeyID.trim().length <= 120 && props.minimumSecurityVersion.trim().length <= 80 && props.releaseNotes.trim().length <= 2000;
|
||
const selectedRelease = readyReleases.find((item) => item.id === props.rolloutReleaseID);
|
||
const compatibleRolloutDevices = selectedRelease ? props.devices.filter((device) => device.status === "registered" && device.model === selectedRelease.model && (!selectedRelease.hardware_revision || device.hardware_revision === selectedRelease.hardware_revision)) : [];
|
||
const rolloutSelectionValid = props.rolloutDeviceIDs.length > 0 && props.rolloutDeviceIDs.every((id) => compatibleRolloutDevices.some((device) => device.id === id));
|
||
async function selectRecoveryFile(file?: File) {
|
||
setRecoveryFile(file); setRecoveryDigest("");
|
||
if (!file || file.size < 1 || file.size > 268435456) return;
|
||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||
setRecoveryDigest(Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""));
|
||
}
|
||
function openRecovery(release: FirmwareRelease) { setRecoveryReleaseID(release.id); setRecoveryFile(undefined); setRecoveryDigest(""); }
|
||
function closeRecovery() { setRecoveryReleaseID(""); setRecoveryFile(undefined); setRecoveryDigest(""); }
|
||
return <div className="firmware-workspace">
|
||
<section className="workspace__tools firmware-tools" aria-label="固件发布操作">
|
||
<form className="tool-form" onSubmit={(event) => void props.onRegisterKey(event)}>
|
||
<div><strong>登记可信签名公钥</strong><span>只登记Ed25519公钥;生产签名私钥不得进入平台。</span></div>
|
||
<label><span>密钥编号</span><input value={props.keyID} maxLength={120} onChange={(event) => props.onKeyID(event.target.value)} placeholder="例如:firmware-prod-2026-01" required /><small>{existingSigningKey ? existingSigningKey.status === "REVOKED" ? "该编号已经吊销,必须使用新的轮换编号。" : "该编号已存在,只允许完全相同公钥的幂等登记。" : "密钥编号一旦登记就与公钥指纹永久绑定。"}</small></label>
|
||
<label className="tool-form__wide"><span>Ed25519公钥(Base64)</span><input value={props.publicKey} onChange={(event) => props.onPublicKey(event.target.value)} placeholder="粘贴32字节公钥的Base64编码" required /><small>{props.publicKey.trim() ? publicKeyValid ? "已识别标准Base64编码的32字节Ed25519公钥。" : "必须是标准Base64编码的32字节Ed25519公钥。" : "平台只保存公钥;生产私钥不得粘贴到网页。"}</small></label>
|
||
<button type="submit" disabled={props.actionBusy || !props.keyID.trim() || props.keyID.trim().length > 120 || !publicKeyValid || existingSigningKey?.status === "REVOKED"}>核对并登记公钥</button>
|
||
</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>
|
||
{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>
|
||
|
||
<form className="tool-form firmware-publish-form" onSubmit={(event) => void props.onPublish(event)}>
|
||
<div><strong>上传并校验固件制品</strong><span>浏览器计算SHA-256;平台核对对象后再验证离线签名。</span></div>
|
||
<label><span>设备型号</span><input value={props.model} maxLength={120} onChange={(event) => props.onModel(event.target.value)} required /></label>
|
||
<label><span>主板版本</span><input value={props.hardware} maxLength={80} onChange={(event) => props.onHardware(event.target.value)} /></label>
|
||
<label><span>固件版本</span><input value={props.version} maxLength={80} onChange={(event) => props.onVersion(event.target.value)} placeholder="例如:2.3.1" required /></label>
|
||
<label><span>签名密钥编号</span><input value={props.signingKeyID} maxLength={120} onChange={(event) => props.onSigningKeyID(event.target.value)} required /></label>
|
||
<label><span>最低安全基线</span><input value={props.minimumSecurityVersion} maxLength={80} onChange={(event) => props.onMinimumSecurityVersion(event.target.value)} placeholder="例如:1.0.0" /></label>
|
||
<label className="tool-form__wide"><span>Ed25519签名(Base64)</span><input value={props.signature} onChange={(event) => props.onSignature(event.target.value)} placeholder="离线签名清单结果" required /><small>{props.signature.trim() ? signatureValid ? "已识别标准Base64编码的64字节Ed25519签名。" : "签名必须是标准Base64编码的64字节Ed25519结果。" : "签名输入必须对应页面展示的firmware-manifest-v1清单。"}</small></label>
|
||
<label className="tool-form__wide"><span>发布说明</span><textarea value={props.releaseNotes} maxLength={2000} onChange={(event) => props.onReleaseNotes(event.target.value)} placeholder="可选;留空时记录上传文件名" /></label>
|
||
<label className="tool-form__wide"><span>固件文件</span><input type="file" accept="application/octet-stream,.bin" onChange={(event) => void props.onFile(event.target.files?.[0])} required /></label>
|
||
<div className="firmware-digest"><span>文件校验</span><strong>{props.file ? `${props.file.name} · ${formatBytes(props.file.size)}` : "尚未选择文件"}</strong><code>{props.digest || "选择文件后计算SHA-256"}</code></div>
|
||
<button type="submit" disabled={props.actionBusy || !props.file || !props.digest || !signatureValid || !publishMetadataValid}>核对并上传校验</button>
|
||
</form>
|
||
|
||
<form className="tool-form firmware-rollout-form" onSubmit={(event) => void props.onStartRollout(event)}>
|
||
<div><strong>创建固件灰度批次</strong><span>只允许选择已完成摘要和签名校验的版本。</span></div>
|
||
<label><span>就绪版本</span><select value={props.rolloutReleaseID} onChange={(event) => { props.onRolloutReleaseID(event.target.value); props.onRolloutDeviceIDs([]); }} required><option value="">选择版本</option>{readyReleases.map((release) => <option key={release.id} value={release.id}>{release.model} / {release.hardware_revision || "全部主板"} / {release.version}</option>)}</select></label>
|
||
<label><span>批次名称</span><input value={props.rolloutName} onChange={(event) => props.onRolloutName(event.target.value)} placeholder="例如:客厅设备首批10%" required /></label>
|
||
<label><span>灰度比例:{props.percentage}%</span><input type="range" min="1" max="100" value={props.percentage} onChange={(event) => props.onPercentage(Number(event.target.value))} /></label>
|
||
<fieldset className="firmware-device-select"><legend>候选设备</legend>{props.devices.map((device) => { const compatible = compatibleRolloutDevices.some((item) => item.id === device.id); return <label key={device.id}><input type="checkbox" disabled={!compatible} checked={props.rolloutDeviceIDs.includes(device.id)} onChange={(event) => props.onRolloutDeviceIDs(event.target.checked ? [...props.rolloutDeviceIDs, device.id] : props.rolloutDeviceIDs.filter((id) => id !== device.id))} /><span>{device.model} · {device.id.slice(0, 8)}{selectedRelease && !compatible ? " · 型号/主板/状态不兼容" : ""}</span></label>; })}</fieldset>
|
||
<button type="submit" disabled={props.actionBusy || !props.rolloutReleaseID || !props.rolloutName.trim() || !rolloutSelectionValid}>核对并启动灰度发布</button>
|
||
</form>
|
||
</section>
|
||
|
||
<div className="firmware-columns">
|
||
<section className="panel-section"><div className="section-heading"><div><p className="eyebrow">VERIFIED ARTIFACTS</p><h3>固件版本</h3></div><span>{props.releases.length}个版本</span></div>
|
||
{props.releases.length ? <div className="stack-list">{props.releases.map((release) => { const runningRollouts = props.rollouts.filter((rollout) => rollout.release_id === release.id && rollout.status === "RUNNING").length; const signingKeyActive = props.signingKeys.some((key) => key.key_id === release.signing_key_id && key.status === "ACTIVE"); return <article className="stack-card" key={release.id}><div><strong>{release.model} · {release.version}</strong><span>{release.hardware_revision || "全部主板"} · {formatBytes(release.expected_size)} · 签名密钥 {release.signing_key_id}</span></div><div className="row-actions"><span className={`status ${release.status.toLowerCase()}`}>{firmwareReleaseStatusLabel(release.status)}</span><code>{release.expected_digest.slice(0, 12)}…</code><button type="button" className="button-small" disabled={props.impactLoading} onClick={() => void props.onOpenImpact(release.id)}>查看影响</button>{release.status === "UPLOADING" ? <><button type="button" className="button-small button-secondary" disabled={props.actionBusy} onClick={() => openRecovery(release)}>恢复上传</button><button type="button" className="button-small button-danger" disabled={props.actionBusy} onClick={() => props.onRetireRelease(release)}>退役未完成版本</button></> : null}{release.status === "READY" ? <><button type="button" className="button-small button-secondary" disabled={props.actionBusy} title={runningRollouts ? `将同时暂停${runningRollouts}个运行中批次并取消未投递命令` : "暂停后阻止新灰度和新下载"} onClick={() => props.onPauseRelease(release)}>暂停分发</button><button type="button" className="button-small button-danger" disabled={props.actionBusy || runningRollouts > 0} title={runningRollouts ? "请先暂停版本,处置关联运行批次后再退役" : "退役后保留已验证制品和历史记录"} onClick={() => props.onRetireRelease(release)}>退役版本</button></> : null}{release.status === "PAUSED" ? <><button type="button" className="button-small button-secondary" disabled={props.actionBusy || !signingKeyActive} title={signingKeyActive ? "恢复后允许创建新灰度,旧批次不会自动恢复" : "签名密钥已吊销,不能恢复分发"} onClick={() => props.onResumeDistribution(release)}>恢复分发</button><button type="button" className="button-small button-danger" disabled={props.actionBusy || runningRollouts > 0} onClick={() => props.onRetireRelease(release)}>退役版本</button></> : null}</div></article>; })}</div> : <p className="empty-copy">尚未上传固件版本。</p>}
|
||
{props.impactLoading ? <StatePanel title="正在汇总版本影响" detail="正在读取关联批次、设备阶段和命令处置结果…" /> : null}
|
||
{props.releaseImpact && !props.impactLoading ? <FirmwareReleaseImpactPanel impact={props.releaseImpact} /> : null}
|
||
{recoveryRelease ? <form className="firmware-recovery-form" aria-label="恢复未完成固件上传" onSubmit={(event) => { event.preventDefault(); if (recoveryMatches) props.onResumeRelease(recoveryRelease, recoveryFile!, recoveryDigest); }}>
|
||
<div><strong>恢复未完成上传</strong><p>{recoveryRelease.model} / {recoveryRelease.hardware_revision || "全部主板"} / {recoveryRelease.version} · 版本编号 {recoveryRelease.id}</p></div>
|
||
<label><span>重新选择原始固件文件</span><input type="file" accept="application/octet-stream,.bin" onChange={(event) => void selectRecoveryFile(event.target.files?.[0])} required /><small>必须同时匹配原记录的大小和完整SHA-256;页面不会用新文件覆盖既有清单。</small></label>
|
||
<div className="firmware-digest"><span>原记录</span><strong>{formatBytes(recoveryRelease.expected_size)}</strong><code>{recoveryRelease.expected_digest}</code></div>
|
||
<div className="firmware-digest"><span>本地文件</span><strong>{recoveryFile ? `${recoveryFile.name} · ${formatBytes(recoveryFile.size)}` : "尚未选择文件"}</strong><code>{recoveryDigest || "选择文件后计算SHA-256"}</code></div>
|
||
{recoveryFile && recoveryDigest ? <p className={recoveryMatches ? "workspace__action-message" : "workspace__inline-error"}>{recoveryMatches ? "文件大小和摘要与未完成版本完全一致,可以继续上传。" : "本地文件与原版本的大小或SHA-256不一致,禁止恢复上传。"}</p> : null}
|
||
<div className="sensitive-access-request__actions"><button type="button" className="button-secondary" onClick={closeRecovery}>取消</button><button type="submit" disabled={props.actionBusy || !recoveryMatches}>核对并恢复上传</button></div>
|
||
</form> : null}
|
||
</section>
|
||
|
||
<section className="panel-section"><div className="section-heading"><div><p className="eyebrow">ROLLOUT CONTROL</p><h3>灰度批次</h3></div><span>{props.rollouts.length}个批次</span></div>
|
||
{props.rollouts.length ? <div className="stack-list">{props.rollouts.map((rollout) => <button type="button" className={`stack-card stack-card--button ${props.selectedRollout?.id === rollout.id ? "selected" : ""}`} key={rollout.id} onClick={() => void props.onOpenRollout(rollout.id)}><div><strong>{rollout.name}</strong><span>{rollout.target_percentage}% · {rollout.targeted}台</span></div><div className="row-actions"><span className={`status ${rollout.status.toLowerCase()}`}>{firmwareRolloutStatusLabel(rollout.status)}</span><span>{rollout.succeeded}成功 / {rollout.failed}失败</span></div></button>)}</div> : <p className="empty-copy">尚未创建固件批次。</p>}
|
||
</section>
|
||
</div>
|
||
|
||
{props.detailLoading ? <StatePanel title="正在读取批次详情" detail="正在汇总设备下载、安装和确认状态…" /> : null}
|
||
{props.selectedRollout ? <section className="panel-section firmware-detail"><div className="section-heading"><div><p className="eyebrow">DEVICE PROGRESS</p><h3>{props.selectedRollout.name}</h3></div><span className={`status ${props.selectedRollout.status.toLowerCase()}`}>{firmwareRolloutStatusLabel(props.selectedRollout.status)}</span></div>
|
||
<div className="firmware-detail__actions">
|
||
<label><span>回滚到已验证版本</span><select value={props.rollbackReleaseID} onChange={(event) => props.onRollbackReleaseID(event.target.value)}><option value="">选择回滚版本</option>{readyReleases.filter((release) => release.id !== props.selectedRollout?.release_id).map((release) => <option key={release.id} value={release.id}>{release.model} / {release.version}</option>)}</select></label>
|
||
{props.selectedRollout.status === "RUNNING" ? <button type="button" className="button-secondary" disabled={props.actionBusy} onClick={() => void props.onPause(props.selectedRollout!)}>暂停批次</button> : null}
|
||
<button type="button" className="button-danger" disabled={props.actionBusy || !props.rollbackReleaseID} onClick={() => void props.onRollback(props.selectedRollout!)}>创建回滚批次</button>
|
||
</div>
|
||
<div className="firmware-assignment-list">{props.selectedRollout.assignments?.map((assignment) => <FirmwareAssignmentCard key={assignment.device_id} assignment={assignment} />) ?? <p className="empty-copy">批次尚无设备分配。</p>}</div>
|
||
</section> : null}
|
||
</div>;
|
||
}
|
||
|
||
function FirmwareAssignmentCard({ assignment }: { assignment: FirmwareAssignment }) {
|
||
const progress = assignment.progress_percent ?? (assignment.status === "CONFIRMED" ? 100 : 0);
|
||
return <article className="firmware-assignment">
|
||
<div><strong>{assignment.device_model}</strong><span>{assignment.device_id.slice(0, 8)} · 原版本 {assignment.previous_version || "未知"}</span></div>
|
||
<div className="firmware-progress"><div><span>{firmwareAssignmentStatusLabel(assignment.status)}</span><strong>{progress}%</strong></div><progress max="100" value={progress} /><small>{firmwareAssignmentPhaseSummary(assignment.last_result)}</small></div>
|
||
{assignment.events?.length ? <ol className="firmware-event-timeline" aria-label="设备固件阶段时间线">{assignment.events.map((event) => <li key={event.id}><time dateTime={event.reported_at}>{formatDate(event.reported_at)}</time><span>{firmwareAssignmentEventSummary(event)}</span>{event.result_code ? <code>{event.result_code}</code> : null}</li>)}</ol> : <p className="firmware-event-timeline__empty">该批次创建于阶段事件留存启用前,暂无逐步时间线。</p>}
|
||
</article>;
|
||
}
|
||
|
||
function FirmwareReleaseImpactPanel({ impact }: { impact: FirmwareReleaseImpact }) {
|
||
return <section className="firmware-impact" aria-label="固件版本影响与处置结果">
|
||
<div className="section-title"><div><strong>版本影响与处置结果</strong><p>版本 {impact.release_id} · {firmwareReleaseStatusLabel(impact.release_status)} · {formatDate(impact.calculated_at)}汇总</p></div><span className={`status ${impact.release_status.toLowerCase()}`}>{impact.devices.observing ? `${impact.devices.observing}台待观察` : "处置已收敛"}</span></div>
|
||
<div className="firmware-impact__metrics">
|
||
<article><span>关联批次</span><strong>{impact.rollouts.total}</strong><small>运行 {impact.rollouts.running} · 暂停 {impact.rollouts.paused} · 完成 {impact.rollouts.completed} · 已回滚 {impact.rollouts.rolled_back}</small></article>
|
||
<article><span>目标设备</span><strong>{impact.devices.total}</strong><small>分配 {impact.devices.assigned} · 下载 {impact.devices.downloading} · 安装 {impact.devices.installing} · 确认 {impact.devices.confirmed} · 失败 {impact.devices.failed}</small></article>
|
||
<article><span>命令处置</span><strong>{impact.commands.cancelled_before_delivery}</strong><small>取消未投递 · 排队 {impact.commands.queued} · 已送达 {impact.commands.delivered} · 已确认 {impact.commands.acknowledged}</small></article>
|
||
<article><span>仍需观察</span><strong>{impact.devices.observing}</strong><small>已进入设备侧流程或回执尚未收敛</small></article>
|
||
</div>
|
||
{impact.observation_devices.length ? <div className="firmware-impact__devices">{impact.observation_devices.map((device) => <article key={`${device.rollout_id}-${device.device_id}`}><div><strong>{device.device_model}</strong><code>{device.device_id}</code><span>{device.rollout_name} · {firmwareRolloutStatusLabel(device.rollout_status)}</span></div><div><span className="status paused">{firmwareObservationReasonLabel(device.observation_reason)}</span><strong>{firmwareAssignmentStatusLabel(device.assignment_status)} · {device.progress_percent ?? 0}%</strong><small>命令 {device.command_status} · {device.last_reported_at ? `${formatDate(device.last_reported_at)}上报` : "尚未上报"}</small></div></article>)}</div> : <p className="empty-copy">当前没有下载中、安装中或回执待收敛的设备。</p>}
|
||
{impact.observation_devices_truncated ? <p className="workspace__inline-error">待观察设备超过100台,当前仅显示前100台;汇总数量仍为完整统计。</p> : null}
|
||
</section>;
|
||
}
|
||
|
||
function DevicePanel({ devices, subjects, presence, twins, canManage, actionBusy, accessToken, onSetVolume, onDiagnostics, onBindingChanged }: { devices: ManagedDevice[]; subjects: Subject[]; presence: Record<string, DevicePresence>; twins: Record<string, DeviceTwin>; canManage: boolean; actionBusy: boolean; accessToken: string; onSetVolume: (device: ManagedDevice, speakerVolume: number) => void; onDiagnostics: (device: ManagedDevice) => Promise<void>; onBindingChanged: () => Promise<void> }) {
|
||
return <section className="device-list-panel" aria-label="已绑定设备">
|
||
<div className="device-list-panel__heading"><div><strong>已绑定设备</strong><span>{devices.length} 台</span></div><p>查看在线状态、当前固件与可配置能力。</p></div>
|
||
{devices.length ? <div className="device-grid">{devices.map((device) => <DeviceCard key={device.id} device={device} subjects={subjects} online={presence[device.id]} twin={twins[device.id]} canManage={canManage} actionBusy={actionBusy} accessToken={accessToken} onSetVolume={onSetVolume} onDiagnostics={onDiagnostics} onBindingChanged={onBindingChanged} />)}</div> : <div className="device-list-empty"><span><LaptopOutlined /></span><h3>这个家庭还没有已绑定设备</h3><p>使用右上角“绑定设备”,输入设备屏幕上的一次性绑定码。</p></div>}
|
||
</section>;
|
||
}
|
||
|
||
function DeviceCard({ device, subjects, online, twin, canManage, actionBusy, accessToken, onSetVolume, onDiagnostics, onBindingChanged }: { device: ManagedDevice; subjects: Subject[]; online?: DevicePresence; twin?: DeviceTwin; canManage: boolean; actionBusy: boolean; accessToken: string; onSetVolume: (device: ManagedDevice, speakerVolume: number) => void; onDiagnostics: (device: ManagedDevice) => Promise<void>; onBindingChanged: () => Promise<void> }) {
|
||
const desiredSpeakerVolume = deviceSpeakerVolume(twin?.desired_config);
|
||
const reportedSpeakerVolume = deviceSpeakerVolume(twin?.reported_config);
|
||
const [speakerVolume, setSpeakerVolume] = useState(desiredSpeakerVolume ?? 60);
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
const [bindingAction, setBindingAction] = useState<"unbind" | "transfer">();
|
||
const [targetSubjectID, setTargetSubjectID] = useState("");
|
||
const [bindingReason, setBindingReason] = useState("");
|
||
const [bindingImpact, setBindingImpact] = useState<DeviceBindingImpact>();
|
||
const [bindingBusy, setBindingBusy] = useState(false);
|
||
const [bindingError, setBindingError] = useState<string>();
|
||
const uploadQueue = twin ? recordingQueueFromTwin(twin) : undefined;
|
||
const transferTargets = subjects.filter((subject) => subject.id !== device.subject_id);
|
||
const volumeValid = Number.isInteger(speakerVolume) && speakerVolume >= 0 && speakerVolume <= 100;
|
||
const volumeChanged = desiredSpeakerVolume === undefined || speakerVolume !== desiredSpeakerVolume;
|
||
|
||
useEffect(() => {
|
||
setSpeakerVolume(desiredSpeakerVolume ?? 60);
|
||
}, [device.id, desiredSpeakerVolume, twin?.desired_revision]);
|
||
|
||
function saveVolume(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!twin || !volumeValid || !volumeChanged) return;
|
||
onSetVolume(device, speakerVolume);
|
||
}
|
||
|
||
function openBindingChange(action: "unbind" | "transfer") {
|
||
const defaultTarget = transferTargets[0]?.id ?? "";
|
||
setBindingAction(action);
|
||
setTargetSubjectID(defaultTarget);
|
||
setBindingReason("");
|
||
setBindingImpact(undefined);
|
||
setBindingError(undefined);
|
||
if (action === "unbind") void reviewBindingImpact(action, "");
|
||
}
|
||
|
||
async function reviewBindingImpact(action = bindingAction, target = targetSubjectID) {
|
||
if (!action || (action === "transfer" && !target)) return;
|
||
setBindingBusy(true);
|
||
setBindingError(undefined);
|
||
try {
|
||
const response = await platformApi.deviceBindingImpact(device.id, action, action === "transfer" ? target : undefined, accessToken);
|
||
setBindingImpact(response.data);
|
||
} catch (cause) {
|
||
setBindingImpact(undefined);
|
||
setBindingError(errorMessage(cause));
|
||
} finally {
|
||
setBindingBusy(false);
|
||
}
|
||
}
|
||
|
||
async function submitBindingChange(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!bindingAction || !bindingImpact?.allowed || bindingReason.trim().length < 3) return;
|
||
setBindingBusy(true);
|
||
setBindingError(undefined);
|
||
try {
|
||
await platformApi.changeDeviceBinding(device.id, {
|
||
action: bindingAction,
|
||
expected_binding_id: bindingImpact.binding_id,
|
||
...(bindingAction === "transfer" ? { target_subject_id: targetSubjectID } : {}),
|
||
reason: bindingReason.trim(),
|
||
acknowledged: true
|
||
}, accessToken);
|
||
setBindingAction(undefined);
|
||
setDetailOpen(false);
|
||
await onBindingChanged();
|
||
} catch (cause) {
|
||
setBindingError(errorMessage(cause));
|
||
setBindingImpact(undefined);
|
||
} finally {
|
||
setBindingBusy(false);
|
||
}
|
||
}
|
||
|
||
return <article className={online?.online ? "device-card device-card--online" : "device-card device-card--offline"}>
|
||
<div className="device-card__main">
|
||
<div className="device-card__identity"><span className="device-card__icon"><LaptopOutlined /><small>{online?.online ? <span /> : <DisconnectOutlined />}</small></span><div><h3>{device.model}<span>{device.subject_name}</span></h3><p><ClockCircleOutlined /> 最后活动:{device.last_reported_at ? formatDate(device.last_reported_at) : "从未上报"}</p></div></div>
|
||
<span className={canManage ? "permission permission--write" : "permission"}><SettingOutlined /> {canManage ? "可配置" : "只读"}</span>
|
||
<div className="device-card__facts"><div><small>在线状态</small><strong><span className={online?.online ? "presence-dot online" : "presence-dot"} />{online?.online ? "在线" : "离线"}</strong></div><div><small>当前固件</small><strong><LaptopOutlined />{device.firmware_version ?? "尚未上报"}</strong></div></div>
|
||
</div>
|
||
<div className="device-card__side"><button type="button" className="button-secondary device-card__manage" onClick={() => setDetailOpen(true)}><ToolOutlined /> 配置与管理</button></div>
|
||
{detailOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}><section className="workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby={`device-detail-${device.id}`}><header><div><h3 id={`device-detail-${device.id}`}>{device.model}</h3><p>{device.subject_name} · {online?.online ? "当前在线" : "当前离线"}</p></div><button type="button" aria-label="关闭" onClick={() => setDetailOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><div className="detail-grid"><Info label="当前固件" value={device.firmware_version ?? "尚未上报"} /><Info label="最低固件" value={device.minimum_firmware_version ?? "未设置门禁"} /><Info label="期望配置" value={String(device.desired_revision)} /><Info label="实际配置" value={device.reported_revision ? String(device.reported_revision) : "尚未应用"} /></div><div className="sync-bar"><span>配置同步</span><strong>{twin ? syncLabel(twin.sync_state) : "读取中"}</strong></div><div className={`upload-queue${uploadQueue?.failedSegments ? " upload-queue--failed" : uploadQueue?.pendingSegments ? " upload-queue--pending" : ""}`}><div><span>录音上传队列</span><strong>{uploadQueue ? uploadQueueSummary(uploadQueue) : "等待设备上报"}</strong></div>{uploadQueue?.failedSegments ? <p>最近失败:{uploadQueue.lastFailureCode || twin?.last_error_code || "终端未上报失败码"}{uploadQueue.lastFailureAt || twin?.last_error_at ? ` · ${formatDate(uploadQueue.lastFailureAt || twin!.last_error_at!)}` : ""}</p> : uploadQueue?.pendingSegments ? <p>终端会继续按上传策略重试;云端校验完整后才允许删除本地密文。</p> : uploadQueue ? <p>当前没有待上传或失败的录音分片。</p> : null}</div>{canManage ? <form className="device-volume" onSubmit={(event) => void saveVolume(event)}>
|
||
<div className="device-volume__head"><label htmlFor={`speaker-volume-${device.id}`}>扬声器音量</label><strong>{speakerVolume}%</strong></div>
|
||
<input id={`speaker-volume-${device.id}`} aria-label={`${device.model}扬声器音量`} type="range" min="0" max="100" step="1" value={speakerVolume} disabled={actionBusy || !twin} onChange={(event) => setSpeakerVolume(Number(event.target.value))} />
|
||
<div className="device-volume__meta"><span>云端期望:{desiredSpeakerVolume === undefined ? "未设置" : `${desiredSpeakerVolume}%`}</span><span>设备上报:{reportedSpeakerVolume === undefined ? "未上报" : `${reportedSpeakerVolume}%`}</span></div>
|
||
<div className="row-actions"><button type="submit" className="button-small device-card__action" disabled={actionBusy || !twin || !volumeValid || !volumeChanged}>保存音量配置</button><button type="button" className="button-small button-secondary" disabled={actionBusy} onClick={() => void onDiagnostics(device)}>下发设备诊断</button></div>
|
||
</form> : null}{canManage ? <section className="device-binding-actions"><div><strong>设备归属</strong><p>解绑或转移前会检查活动通话、未完成命令和固件任务。</p></div><div className="row-actions"><button type="button" className="button-small button-secondary" disabled={actionBusy || transferTargets.length === 0} onClick={() => openBindingChange("transfer")}><UserOutlined /> 转移使用人</button><button type="button" className="button-small button-danger" disabled={actionBusy} onClick={() => openBindingChange("unbind")}><DeleteOutlined /> 解绑设备</button></div></section> : null}</div></section></div> : null}
|
||
{bindingAction ? <div className="workspace-dialog-backdrop workspace-dialog-backdrop--nested" role="presentation"><form className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby={`binding-change-${device.id}`} onSubmit={(event) => void submitBindingChange(event)}><header><div><h3 id={`binding-change-${device.id}`}>{bindingAction === "transfer" ? "转移设备使用人" : "解绑设备"}</h3><p>{device.model} · 当前使用人 {device.subject_name}</p></div><button type="button" aria-label="关闭" onClick={() => setBindingAction(undefined)}><CloseOutlined /></button></header><div className="workspace-dialog__body">{bindingAction === "transfer" ? <label><span>新的使用人</span><select value={targetSubjectID} onChange={(event) => { setTargetSubjectID(event.target.value); setBindingImpact(undefined); }}><option value="">请选择长者</option>{transferTargets.map((subject) => <option key={subject.id} value={subject.id}>{subject.preferred_name}</option>)}</select></label> : null}<label><span>变更原因</span><textarea value={bindingReason} minLength={3} maxLength={300} onChange={(event) => setBindingReason(event.target.value)} placeholder="说明设备停用、维修、更换使用人等原因" required /></label><button type="button" className="button-secondary" disabled={bindingBusy || (bindingAction === "transfer" && !targetSubjectID)} onClick={() => void reviewBindingImpact()}>{bindingBusy ? "正在检查…" : "重新检查影响"}</button>{bindingError ? <p className="workspace__inline-error" role="alert">{bindingError}</p> : null}{bindingImpact ? <div className={bindingImpact.allowed ? "device-binding-impact device-binding-impact--allowed" : "device-binding-impact device-binding-impact--blocked"}><strong>{bindingImpact.allowed ? "可以执行" : "当前不可执行"}</strong><span>活动通话:{bindingImpact.active_conversation_id ? "1" : "0"}</span><span>未完成命令:{bindingImpact.pending_command_count}</span><span>固件任务:{bindingImpact.active_firmware_task_count}</span>{bindingImpact.blockers.length ? <p>{bindingImpact.blockers.map(deviceBindingBlockerLabel).join(";")}</p> : <p>未发现阻断项,提交时服务端会再次检查。</p>}</div> : null}</div><footer><button type="button" className="button-secondary" onClick={() => setBindingAction(undefined)}>取消</button><button type="submit" className={bindingAction === "unbind" ? "button-danger" : ""} disabled={bindingBusy || !bindingImpact?.allowed || bindingReason.trim().length < 3}>{bindingBusy ? "正在处理…" : bindingAction === "transfer" ? "确认转移" : "确认解绑"}</button></footer></form></div> : null}
|
||
</article>;
|
||
}
|
||
|
||
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[];
|
||
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>;
|
||
loading: boolean; detailLoading: boolean; loadingMore: boolean; error?: string; detailError?: string; selectedConversationID?: string;
|
||
status: string; onStatus: (value: string) => void; engineMode: string; onEngineMode: (value: string) => void;
|
||
from: string; onFrom: (value: string) => void; to: string; onTo: (value: string) => void;
|
||
hasMore: boolean; onResetFilters: () => void; onLoadMore: () => Promise<void>; onOpen: (id: string) => Promise<void>;
|
||
onGrant: (input: RecordingGrantInput) => void;
|
||
onRevoke: (grant: RecordingAccessGrant) => Promise<void>;
|
||
onAccess: (segment: RecordingSegment, action: "PLAY" | "DOWNLOAD", purpose: string) => void;
|
||
}) {
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
return <div className="conversation-layout">
|
||
<div className="conversation-list">
|
||
<div className="conversation-filter">
|
||
<div className="conversation-filter__fields">
|
||
<label><span>长者</span><select disabled={loadingMore} value={selectedID ?? ""} onChange={(event) => onSelect(event.target.value)}>{subjects.map((subject) => <option value={subject.id} key={subject.id}>{subject.preferred_name}</option>)}</select></label>
|
||
<label><span>通话状态</span><select disabled={loadingMore} value={status} onChange={(event) => onStatus(event.target.value)}><option value="">全部状态</option><option value="ACTIVE">通话中</option><option value="COMPLETED">已完成</option><option value="FAILED">失败</option><option value="EXPIRED">已过期</option><option value="PREPARING">准备中</option><option value="ROOM_ISSUED">房间已分配</option><option value="STARTING_AGENT">智能体启动中</option><option value="STOPPING">结束中</option></select></label>
|
||
<label><span>语音处理</span><select disabled={loadingMore} value={engineMode} onChange={(event) => onEngineMode(event.target.value)}><option value="">全部方式</option><option value="s2s_pure">端到端语音</option><option value="s2s_hybrid">端到端混合</option><option value="cascade">级联处理</option></select></label>
|
||
<label><span>开始日期</span><input disabled={loadingMore} type="date" value={from} max={to || undefined} onChange={(event) => onFrom(event.target.value)} /></label>
|
||
<label><span>结束日期</span><input disabled={loadingMore} type="date" value={to} min={from || undefined} onChange={(event) => onTo(event.target.value)} /></label>
|
||
</div>
|
||
<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>}
|
||
{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 ? <>
|
||
<div className="resource-detail__head"><div><p className="eyebrow">CONVERSATION DETAIL</p><h3>{conversationStatusLabel(detail.status)}</h3><p>{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}</p></div></div>
|
||
{detail.failure_code ? <p className="recording-notice recording-notice--error" role="alert"><strong>{conversationFailureLabel(detail.failure_code) ?? "通话准备失败"}</strong><br />设备收到的失败标识:{detail.failure_code};背景准备状态:{detail.snapshots.context_preparation_status}</p> : null}
|
||
<div className="detail-grid"><Info label="语音处理" value={engineModeLabel(detail.engine_mode)} /><Info label="保存策略" value={retentionModeLabel(detail.retention_mode)} /><Info label="授权快照" value={`v${detail.snapshots.consent_snapshot_version}`} /><Info label="档案快照" value={`v${detail.snapshots.profile_version}`} /></div>
|
||
<section className="conversation-usage"><div><span>本次通话已核价费用</span><strong>{formatUsageMoney(usage?.cost_micros ?? 0, usage?.currency || "CNY")}</strong><small>{usage?.lines.length ?? 0} 个计量项;供应商内部任务编号不会展示</small></div>{usage?.lines.length ? <ul>{usage.lines.map((line) => <li key={`${line.usage_category}-${line.metric_code}-${line.unit}-${line.currency}`}><span>{usageCategoryLabel(line.usage_category)} · {line.quantity} {line.unit}</span><strong>{formatUsageMoney(line.cost_micros, line.currency)}</strong></li>)}</ul> : <p>供应商最终用量尚未到达,或本次通话没有产生已核价费用。</p>}</section>
|
||
<RecordingMediaBlock retentionMode={detail.retention_mode} media={media} state={mediaState} error={mediaError}
|
||
grants={grants} members={members} canManage={canManageRecordingAccess} actionBusy={actionBusy} audioURLs={audioURLs}
|
||
onGrant={onGrant} onRevoke={onRevoke} onAccess={onAccess} />
|
||
<section className="content-block"><div className="section-title"><h4>通话整理</h4><span>{summaries.length}</span></div>{summaries.length ? summaries.map((item) => <article className="summary-card" key={item.id}><strong>{item.type === "conversation_summary" ? "通话摘要" : "开放话题"}</strong><p>{item.content}</p><small>生成版本 v{item.generation_version} · 来源话轮 {item.source_turn_ids.length} 条</small></article>) : <p className="empty-copy">本次通话没有可展示的整理结果。</p>}</section>
|
||
<section className="content-block"><div className="section-title"><h4>最终话轮</h4><span>{turns.length}</span></div>{turns.length ? turns.map((turn) => <article className={`turn-row turn-row--${turn.speaker}`} key={turn.id}><div><strong>{turn.speaker === "subject" ? "长者" : "智能体"}</strong><span>#{turn.sequence_no}{turn.interrupted ? " · 已被打断" : ""}</span></div><p>{turn.final_text}</p></article>) : <p className="empty-copy">授权未允许保存字幕,或最终话轮尚未到达。</p>}</section>
|
||
<div className="timeline"><h4>状态历史</h4>{detail.history.map((entry, index) => <div className="timeline__item" key={`${entry.occurred_at}-${index}`}><span /><div><strong>{conversationStatusLabel(entry.to_status)}</strong><p>{formatDate(entry.occurred_at)} · {conversationSourceLabel(entry.source)}</p></div></div>)}</div>
|
||
</> : <StatePanel title="请选择一条通话" detail="详情展示脱敏状态、最终话轮、整理结果和完整状态历史。" />}</div></section></div> : null}
|
||
</div>;
|
||
}
|
||
|
||
function RecordingMediaBlock({ retentionMode, media, state, error, grants, members, canManage, actionBusy, audioURLs, onGrant, onRevoke, onAccess }: {
|
||
retentionMode: string; media?: ConversationMedia; state: MediaLoadState; error?: string; grants: RecordingAccessGrant[]; members: HouseholdMember[];
|
||
canManage: boolean; actionBusy: boolean; audioURLs: Record<string, string>;
|
||
onGrant: (input: RecordingGrantInput) => void;
|
||
onRevoke: (grant: RecordingAccessGrant) => Promise<void>;
|
||
onAccess: (segment: RecordingSegment, action: "PLAY" | "DOWNLOAD", purpose: string) => void;
|
||
}) {
|
||
const activeMembers = members.filter((member) => member.status === "active");
|
||
const [granteeID, setGranteeID] = useState(activeMembers[0]?.user_id ?? "");
|
||
const [permission, setPermission] = useState<"PLAY" | "DOWNLOAD">("PLAY");
|
||
const [purpose, setPurpose] = useState("家庭回听");
|
||
const [validHours, setValidHours] = useState(1);
|
||
useEffect(() => {
|
||
if (!activeMembers.some((member) => member.user_id === granteeID)) setGranteeID(activeMembers[0]?.user_id ?? "");
|
||
}, [activeMembers, granteeID]);
|
||
|
||
const now = Date.now();
|
||
const activeGrants = grants.filter((grant) => !grant.revoked_at && new Date(grant.expires_at).getTime() > now);
|
||
const accessPurpose = purpose.trim() || "家庭回听";
|
||
function submitGrant(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!granteeID || !purpose.trim()) return;
|
||
onGrant({ grantee_user_id: granteeID, permission, purpose: purpose.trim(), expires_at: new Date(Date.now() + validHours * 60 * 60 * 1000).toISOString() });
|
||
}
|
||
|
||
return <section className="content-block recording-block">
|
||
<div className="section-title"><h4>聊天语音</h4><span>{media?.segments.length ?? 0}</span></div>
|
||
{retentionMode !== "transcript_and_audio" ? <p className="recording-notice">本次通话的冻结保存策略不包含原录音,平台不会提供播放或下载。</p> : <>
|
||
{state === "loading" ? <p className="empty-copy">正在核验原录音授权和分片状态…</p> : null}
|
||
{state === "restricted" ? <p className="recording-notice recording-notice--restricted">家庭角色不会自动获得录音权限。请由家庭所有者或管理员按用途分别授予播放或下载权限。</p> : null}
|
||
{state === "error" ? <p className="recording-notice recording-notice--error">录音状态读取失败:{error ?? "未知错误"}</p> : null}
|
||
{state === "ready" && media ? <>
|
||
<div className="recording-capabilities"><span className={media.can_play ? "status ready" : "status"}>播放{media.can_play ? "已授权" : "未授权"}</span><span className={media.can_download ? "status ready" : "status"}>下载{media.can_download ? "已授权" : "未授权"}</span><small>每次操作都会重新核验并写入访问审计,短期地址5分钟失效。</small></div>
|
||
<div className="recording-segments">{media.segments.length ? media.segments.map((segment) => <article className="recording-segment" key={segment.id}>
|
||
<div><strong>{segment.track === "near" ? "近端(长者)" : "远端(智能体)"} · #{segment.sequence_no}</strong><span>{Math.round(segment.duration_ms / 100) / 10}秒 · {formatBytes(Number(segment.expected_size))} · 已校验</span></div>
|
||
<div className="row-actions">{media.can_play ? <button type="button" className="button-small" disabled={actionBusy} onClick={() => onAccess(segment, "PLAY", accessPurpose)}>{segment.track === "near" ? "听长者语音" : "听智能体语音"}</button> : null}{media.can_download ? <button type="button" className="button-small button-secondary" disabled={actionBusy} onClick={() => onAccess(segment, "DOWNLOAD", accessPurpose)}>下载</button> : null}</div>
|
||
{audioURLs[segment.id] ? <audio className="recording-audio" controls autoPlay preload="none" src={audioURLs[segment.id]}>当前浏览器不支持音频播放。</audio> : null}
|
||
</article>) : <p className="empty-copy">当前没有已上传并通过校验的聊天语音。</p>}</div>
|
||
</> : null}
|
||
{canManage ? <div className="recording-admin">
|
||
<form className="recording-grant-form" onSubmit={(event) => void submitGrant(event)}>
|
||
<div><strong>显式授权</strong><span>播放与下载必须分开授予;新授权会撤销同一成员的旧同类授权。</span></div>
|
||
<label><span>家庭成员</span><select value={granteeID} onChange={(event) => setGranteeID(event.target.value)}>{activeMembers.map((member) => <option key={member.user_id} value={member.user_id}>{member.display_name || member.user_id.slice(0, 8)} · {roleLabel(member.role)}</option>)}</select></label>
|
||
<label><span>权限</span><select value={permission} onChange={(event) => setPermission(event.target.value as "PLAY" | "DOWNLOAD")}><option value="PLAY">仅播放</option><option value="DOWNLOAD">仅下载</option></select></label>
|
||
<label><span>有效期</span><select value={validHours} onChange={(event) => setValidHours(Number(event.target.value))}><option value={1}>1小时</option><option value={8}>8小时</option><option value={24}>24小时</option></select></label>
|
||
<label><span>使用目的</span><input value={purpose} maxLength={120} onChange={(event) => setPurpose(event.target.value)} /></label>
|
||
<button type="submit" disabled={actionBusy || !granteeID || !purpose.trim()}>确认授权</button>
|
||
</form>
|
||
<div className="recording-grants"><div className="section-title"><h4>当前有效授权</h4><span>{activeGrants.length}</span></div>{activeGrants.length ? activeGrants.map((grant) => {
|
||
const member = activeMembers.find((item) => item.user_id === grant.grantee_user_id);
|
||
return <article key={grant.id}><div><strong>{member?.display_name || grant.grantee_user_id.slice(0, 8)} · {grant.permission === "PLAY" ? "播放" : "下载"}</strong><span>{grant.purpose} · {formatDate(grant.expires_at)}到期</span></div><button type="button" className="button-small button-danger" disabled={actionBusy} onClick={() => void onRevoke(grant)}>立即撤销</button></article>;
|
||
}) : <p className="empty-copy">没有有效授权。</p>}</div>
|
||
</div> : null}
|
||
</>}
|
||
</section>;
|
||
}
|
||
|
||
function StoryPanel({ selectedID, stories, memories, selected, loading, actionBusy, canReview, onOpen, onOpenConversation, onRevise, onConfirm, onReject, onUpdateFact, onUpdateMemory, onDeleteMemory }: {
|
||
selectedID?: string; stories: Story[]; memories: CuratedMemory[];
|
||
selected?: Story; loading: boolean; actionBusy: boolean; canReview: boolean; onOpen: (id: string) => Promise<void>;
|
||
onOpenConversation: (story: Story) => Promise<void>;
|
||
onRevise: (story: Story, content: string, reason: string, errorType?: ContentErrorType) => Promise<void>;
|
||
onConfirm: (story: Story, reason: string) => Promise<void>; onReject: (story: Story, reason: string, errorType?: ContentErrorType) => Promise<void>;
|
||
onUpdateFact: (story: Story, fact: FactCandidate, input: { fact_type?: string; content?: string; sensitivity?: "normal" | "sensitive" | "high"; status?: "CANDIDATE" | "CONFIRMED" | "REJECTED" | "SUPERSEDED"; error_type?: ContentErrorType; reason: string }) => Promise<boolean>;
|
||
onUpdateMemory: (memory: CuratedMemory, input: { content?: string; status?: "ACTIVE" | "DISABLED"; error_type?: ContentErrorType; reason: string }) => Promise<boolean>;
|
||
onDeleteMemory: (memory: CuratedMemory, reason: string) => Promise<boolean>;
|
||
}) {
|
||
const [content, setContent] = useState("");
|
||
const [reason, setReason] = useState("");
|
||
const [errorType, setErrorType] = useState<ContentErrorType | "">("");
|
||
const [memoryQuery, setMemoryQuery] = useState("");
|
||
const [selectedMemoryID, setSelectedMemoryID] = useState<string>();
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
useEffect(() => { setContent(selected?.content ?? ""); setReason(""); setErrorType(""); }, [selected]);
|
||
const reviewable = canReview && (selected?.status === "CANDIDATE" || selected?.status === "IN_REVIEW");
|
||
const visibleMemories = memories.filter((memory) => memory.content.toLocaleLowerCase().includes(memoryQuery.trim().toLocaleLowerCase()));
|
||
const selectedMemory = memories.find((memory) => memory.id === selectedMemoryID);
|
||
useEffect(() => { if (selectedMemoryID && !memories.some((memory) => memory.id === selectedMemoryID)) setSelectedMemoryID(undefined); }, [memories, selectedMemoryID]);
|
||
useEffect(() => { setSelectedMemoryID(undefined); setDetailOpen(false); setMemoryQuery(""); }, [selectedID]);
|
||
return <div className="story-layout story-board">
|
||
<section className="story-candidates"><div className="story-board__section-title"><h3>故事候选 <span>{stories.length}</span></h3></div><div className="story-candidate-grid">
|
||
{stories.map((story) => <button type="button" className={!selectedMemory && selected?.id === story.id ? "story-candidate-card active" : "story-candidate-card"} key={story.id} onClick={() => { setSelectedMemoryID(undefined); setDetailOpen(true); void onOpen(story.id); }}><span className="story-candidate-card__status"><HourglassOutlined /> {storyStatusLabel(story.status)}</span><strong>{story.title}</strong><small><TagOutlined /> v{story.revision_no}</small></button>)}
|
||
{!stories.length ? <div className="story-board-empty story-board-empty--compact"><span><BookOutlined /></span><h3>暂无故事候选</h3><p>通话整理出的候选会显示在这里,等待家庭成员审核。</p></div> : null}
|
||
</div></section>
|
||
<section className="confirmed-memories"><div className="story-board__section-title story-board__section-title--bordered"><h3>已确认记忆 <small>{visibleMemories.length}/{memories.length}</small></h3></div><label className="memory-search-field"><SearchOutlined /><input value={memoryQuery} maxLength={200} onChange={(event) => setMemoryQuery(event.target.value)} placeholder="搜索当前长者的记忆" /></label>
|
||
{visibleMemories.length ? <div className="confirmed-memory-grid">{visibleMemories.map((memory) => <article className={selectedMemory?.id === memory.id ? "confirmed-memory-card selected" : "confirmed-memory-card"} key={memory.id}><button type="button" disabled={actionBusy} onClick={() => { setSelectedMemoryID(memory.id); setDetailOpen(true); }}><strong>{memory.content}</strong><span>{memoryStatusLabel(memory.status)} · 本地{memoryIndexLabel(memory.index_status)} · 外部{providerMemoryIndexLabel(memory.provider_index_status)} · v{memory.version_no}</span></button><button type="button" className="button-small button-secondary" disabled={actionBusy} onClick={() => { setSelectedMemoryID(memory.id); setDetailOpen(true); }}>{canReview ? "管理" : "查看"}</button></article>)}</div> : <div className="story-board-empty"><span><BookOutlined /></span><h3>{memoryQuery.trim() ? "没有匹配的记忆" : "暂无已确认记忆"}</h3><p>{memoryQuery.trim() ? "请尝试其他关键词。" : "审核上方的故事候选,通过后的内容会沉淀在这里。"}</p></div>}
|
||
</section>
|
||
{detailOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}><section className="story-detail workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby="story-detail-title"><header><div><h3 id="story-detail-title">{selectedMemory ? "记忆详情" : "故事详情"}</h3><p>查看来源并执行经过授权的审核或管理操作</p></div><button type="button" aria-label="关闭" onClick={() => setDetailOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">{selectedMemory ? <MemoryManagementPanel memory={selectedMemory} actionBusy={actionBusy} canReview={canReview} onUpdate={onUpdateMemory} onDelete={onDeleteMemory} onBack={() => setSelectedMemoryID(undefined)} /> : loading && !selected ? <StatePanel title="正在读取故事" detail="加载当前版本、原话来源和事实候选…" /> : selected ? <>
|
||
<div className="resource-detail__head"><div><p className="eyebrow">HUMAN REVIEW</p><h3>{selected.title}</h3><p>{storyStatusLabel(selected.status)} · 第{selected.revision_no}版 · {selected.author_type === "user" ? "人工修订" : "模型候选"}</p></div><div className="story-source-actions"><span className={`tag tag--${selected.status.toLowerCase()}`}>{storyStatusLabel(selected.status)}</span><button type="button" className="button-small button-secondary" disabled={loading} onClick={() => void onOpenConversation(selected)}>查看完整原始通话</button></div></div>
|
||
<label className="story-editor"><span>故事正文</span><textarea value={content} rows={9} disabled={!reviewable || actionBusy} onChange={(event) => setContent(event.target.value)} /></label>
|
||
{reviewable ? <div className="story-review-fields"><label className="story-reason"><span>审核或修订原因</span><input value={reason} disabled={actionBusy} onChange={(event) => setReason(event.target.value)} placeholder="必填,用于审计和版本追踪" /></label><label><span>发现的问题类型</span><select value={errorType} disabled={actionBusy} onChange={(event) => setErrorType(event.target.value as ContentErrorType | "")}><option value="">确认无问题时可不选</option>{contentErrorTypeOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label></div> : null}
|
||
{reviewable ? <div className="story-actions"><button type="button" disabled={actionBusy || !reason.trim() || !errorType || !content.trim() || content === selected.content} onClick={() => void onRevise(selected, content.trim(), reason.trim(), errorType || undefined)}>保存新版本</button><button type="button" className="button-secondary" disabled={actionBusy || !reason.trim()} onClick={() => void onConfirm(selected, reason.trim())}>确认并生成记忆</button><button type="button" className="button-danger" disabled={actionBusy || !reason.trim() || !errorType} onClick={() => void onReject(selected, reason.trim(), errorType || undefined)}>拒绝候选</button></div> : <p className="workspace__readonly-note">{canReview ? "该候选已经进入终态,历史版本和来源仍保留为只读证据。" : "当前家庭角色只能查看故事、来源和记忆,不能执行审核操作。"}</p>}
|
||
<div className="section-columns"><section><div className="section-title"><h4>原话来源</h4><span>{selected.sources.length}</span></div>{selected.sources.map((source) => <article className="source-row" key={source.turn_id}><strong>话轮 #{source.sequence_no} · {source.speaker === "subject" ? "长者" : "智能体"}</strong><p>{source.text}</p></article>)}</section><section><div className="section-title"><h4>事实候选</h4><span>{selected.facts.length}</span></div>{selected.facts.map((fact) => <FactCandidateReview key={fact.id} story={selected} fact={fact} canReview={reviewable} actionBusy={actionBusy} onUpdate={onUpdateFact} />)}</section></div>
|
||
</> : <StatePanel title="请选择一个故事候选" detail="人工查看原话来源后,可以修订、确认或拒绝;系统不会自动写入长期记忆。" />}</div></section></div> : null}
|
||
</div>;
|
||
}
|
||
|
||
function FactCandidateReview({ story, fact, canReview, actionBusy, onUpdate }: {
|
||
story: Story; fact: FactCandidate; canReview: boolean; actionBusy: boolean;
|
||
onUpdate: (story: Story, fact: FactCandidate, input: { fact_type?: string; content?: string; sensitivity?: "normal" | "sensitive" | "high"; status?: "CANDIDATE" | "CONFIRMED" | "REJECTED" | "SUPERSEDED"; error_type?: ContentErrorType; reason: string }) => Promise<boolean>;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [factType, setFactType] = useState(fact.fact_type);
|
||
const [content, setContent] = useState(fact.content);
|
||
const [sensitivity, setSensitivity] = useState<"normal" | "sensitive" | "high">(fact.sensitivity);
|
||
const [reason, setReason] = useState("");
|
||
const [errorType, setErrorType] = useState<ContentErrorType | "">("");
|
||
useEffect(() => { setFactType(fact.fact_type); setContent(fact.content); setSensitivity(fact.sensitivity); setReason(""); setErrorType(""); setEditing(false); }, [fact]);
|
||
|
||
async function decide(status: "CONFIRMED" | "REJECTED" | "SUPERSEDED") {
|
||
if (!reason.trim() || (status !== "CONFIRMED" && !errorType)) return;
|
||
await onUpdate(story, fact, { status, error_type: errorType || undefined, reason: reason.trim() });
|
||
}
|
||
|
||
async function save(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!factType.trim() || !content.trim() || !reason.trim() || !errorType) return;
|
||
await onUpdate(story, fact, { fact_type: factType.trim(), content: content.trim(), sensitivity, status: "CANDIDATE", error_type: errorType, reason: reason.trim() });
|
||
}
|
||
|
||
const mutable = canReview && fact.status === "CANDIDATE";
|
||
const disableOnly = canReview && fact.status === "CONFIRMED";
|
||
return <article className="fact-review-card">
|
||
<div className="fact-review-card__head"><div><strong>{fact.fact_type}</strong><span>{fact.statement_basis === "subject_statement" ? "长者明确陈述" : "模型推断,必须人工核对"} · 来源 {fact.source_turn_ids.length} 条</span></div><span className={`tag tag--${fact.status.toLowerCase()}`}>{factStatusLabel(fact.status.toLowerCase())}</span></div>
|
||
{editing && mutable ? <form className="fact-review-form" onSubmit={(event) => void save(event)}><label><span>事实类型</span><input value={factType} maxLength={64} onChange={(event) => setFactType(event.target.value)} /></label><label><span>事实内容</span><textarea value={content} maxLength={4000} onChange={(event) => setContent(event.target.value)} /></label><label><span>敏感级别</span><select value={sensitivity} onChange={(event) => setSensitivity(event.target.value as typeof sensitivity)}><option value="normal">普通</option><option value="sensitive">敏感</option><option value="high">高度敏感</option></select></label><label><span>问题类型</span><select value={errorType} onChange={(event) => setErrorType(event.target.value as ContentErrorType | "")}><option value="">请选择</option>{contentErrorTypeOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label><label><span>修改原因</span><input value={reason} maxLength={500} onChange={(event) => setReason(event.target.value)} /></label><div className="row-actions"><button type="button" className="button-secondary" onClick={() => setEditing(false)}>取消</button><button type="submit" disabled={actionBusy || !factType.trim() || !content.trim() || !reason.trim() || !errorType}>保存修改</button></div></form> : <><p>{fact.content}</p><div className="fact-review-card__meta"><span>敏感级别:{factSensitivityLabel(fact.sensitivity)}</span>{fact.confidence !== undefined ? <span>模型置信度:{Math.round(fact.confidence * 100)}%</span> : null}</div>{mutable || disableOnly ? <><label className="fact-review-reason"><span>审核原因</span><input value={reason} maxLength={500} onChange={(event) => setReason(event.target.value)} placeholder="必填,记录核对依据" /></label><label className="fact-review-reason"><span>问题类型</span><select value={errorType} onChange={(event) => setErrorType(event.target.value as ContentErrorType | "")}><option value="">确认无问题时可不选</option>{contentErrorTypeOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label><div className="row-actions">{mutable ? <><button type="button" className="button-small button-secondary" disabled={actionBusy} onClick={() => setEditing(true)}><EditOutlined /> 编辑</button><button type="button" className="button-small" disabled={actionBusy || !reason.trim()} onClick={() => void decide("CONFIRMED")}>确认</button><button type="button" className="button-small button-danger" disabled={actionBusy || !reason.trim() || !errorType} onClick={() => void decide("REJECTED")}>拒绝</button></> : <button type="button" className="button-small button-danger" disabled={actionBusy || !reason.trim() || !errorType} onClick={() => void decide("SUPERSEDED")}>停用已确认事实</button>}</div></> : null}</>}
|
||
</article>;
|
||
}
|
||
|
||
function MemoryManagementPanel({ memory, actionBusy, canReview, onUpdate, onDelete, onBack }: {
|
||
memory: CuratedMemory; actionBusy: boolean; canReview: boolean;
|
||
onUpdate: (memory: CuratedMemory, input: { content?: string; status?: "ACTIVE" | "DISABLED"; error_type?: ContentErrorType; reason: string }) => Promise<boolean>;
|
||
onDelete: (memory: CuratedMemory, reason: string) => Promise<boolean>; onBack: () => void;
|
||
}) {
|
||
const [mode, setMode] = useState<"EDIT" | "STATUS" | "DELETE">("EDIT");
|
||
const [content, setContent] = useState(memory.content);
|
||
const [reason, setReason] = useState("");
|
||
const [errorType, setErrorType] = useState<ContentErrorType | "">("");
|
||
const [deleteConfirmed, setDeleteConfirmed] = useState(false);
|
||
const mutable = memory.status === "ACTIVE" || memory.status === "DISABLED";
|
||
|
||
useEffect(() => {
|
||
setContent(memory.content);
|
||
setReason("");
|
||
setErrorType("");
|
||
setDeleteConfirmed(false);
|
||
if (memory.status === "PENDING_DELETE" || memory.status === "DELETED" || memory.status === "SUPERSEDED") setMode("EDIT");
|
||
}, [memory.id, memory.version, memory.content, memory.status]);
|
||
|
||
async function submitEdit(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!mutable || !content.trim() || content.trim() === memory.content || !reason.trim() || !errorType) return;
|
||
if (await onUpdate(memory, { content: content.trim(), error_type: errorType, reason: reason.trim() })) { setReason(""); setErrorType(""); }
|
||
}
|
||
async function submitStatus(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!mutable || !reason.trim() || (memory.status === "ACTIVE" && !errorType)) return;
|
||
const nextStatus = memory.status === "ACTIVE" ? "DISABLED" : "ACTIVE";
|
||
if (await onUpdate(memory, { status: nextStatus, error_type: errorType || undefined, reason: reason.trim() })) { setReason(""); setErrorType(""); }
|
||
}
|
||
async function submitDelete(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!mutable || !deleteConfirmed || !reason.trim()) return;
|
||
if (await onDelete(memory, reason.trim())) { setReason(""); setDeleteConfirmed(false); }
|
||
}
|
||
function changeMode(nextMode: typeof mode) { setMode(nextMode); setReason(""); setErrorType(""); setDeleteConfirmed(false); setContent(memory.content); }
|
||
|
||
return <section className="memory-management" aria-label="记忆管理">
|
||
<div className="resource-detail__head"><div><p className="eyebrow">CURATED MEMORY</p><h3>确认记忆管理</h3><p>正文版本 v{memory.version_no} · 资源版本 v{memory.version}</p></div><div className="story-source-actions"><span className={`status ${memory.status.toLowerCase()}`}>{memoryStatusLabel(memory.status)}</span><button type="button" className="button-small button-secondary" onClick={onBack}>返回故事审核</button></div></div>
|
||
<div className="detail-grid"><Info label="当前状态" value={memoryStatusLabel(memory.status)} /><Info label="本地索引" value={memoryIndexLabel(memory.index_status)} /><Info label="外部记忆" value={providerMemoryIndexLabel(memory.provider_index_status)} /><Info label="来源数量" value={String(memory.source_refs.length)} /><Info label="最近变更" value={formatDate(memory.updated_at)} /></div>
|
||
{memory.provider_index_error ? <p className="recording-notice recording-notice--error" role="status">外部记忆同步失败:{memory.provider_index_error}。本地数据库仍是主数据,新通话继续使用本地检索。</p> : null}
|
||
<div className="memory-management__content"><span>当前记忆正文</span><p>{memory.content}</p>{memory.change_reason ? <small>最近变更原因:{memory.change_reason}</small> : null}</div>
|
||
{memory.status === "PENDING_DELETE" ? <p className="recording-notice recording-notice--error" role="status">删除请求已受理,记忆已停止召回;当前只能等待后台完成索引和正文清理,服务端禁止重新启用或修订。</p> : null}
|
||
{memory.status === "SUPERSEDED" || memory.status === "DELETED" ? <p className="workspace__readonly-note">该记忆已进入只读终态,历史版本仅用于审计追溯。</p> : null}
|
||
{canReview && mutable ? <>
|
||
<nav className="memory-management__tabs" aria-label="记忆操作"><button type="button" className={mode === "EDIT" ? "active" : ""} onClick={() => changeMode("EDIT")}>修订正文</button><button type="button" className={mode === "STATUS" ? "active" : ""} onClick={() => changeMode("STATUS")}>{memory.status === "ACTIVE" ? "停用记忆" : "重新启用"}</button><button type="button" className={mode === "DELETE" ? "active danger" : "danger"} onClick={() => changeMode("DELETE")}>永久删除</button></nav>
|
||
{mode === "EDIT" ? <form className="memory-management__form" onSubmit={(event) => void submitEdit(event)}><label><span>修订后的记忆正文</span><textarea required autoFocus maxLength={50000} rows={7} value={content} onChange={(event) => setContent(event.target.value)} /></label><label><span>问题类型</span><select required value={errorType} onChange={(event) => setErrorType(event.target.value as ContentErrorType | "")}><option value="">请选择本次修订解决的问题</option>{contentErrorTypeOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label><label><span>修订原因</span><input required maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="用于版本追踪和审计" /></label><button type="submit" disabled={actionBusy || !content.trim() || content.trim() === memory.content || !reason.trim() || !errorType}>{actionBusy ? "正在保存…" : "保存为新版本"}</button></form> : null}
|
||
{mode === "STATUS" ? <form className="memory-management__form" onSubmit={(event) => void submitStatus(event)}><p>{memory.status === "ACTIVE" ? "停用后立即停止在新通话中召回,当前索引会失效。" : "重新启用前服务端会再次检查个性化记忆授权,并重新建立索引。"}</p>{memory.status === "ACTIVE" ? <label><span>问题类型</span><select required value={errorType} onChange={(event) => setErrorType(event.target.value as ContentErrorType | "")}><option value="">请选择停用原因类型</option>{contentErrorTypeOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label> : null}<label><span>{memory.status === "ACTIVE" ? "停用原因" : "重新启用原因"}</span><input required autoFocus maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} /></label><button type="submit" disabled={actionBusy || !reason.trim() || (memory.status === "ACTIVE" && !errorType)}>{actionBusy ? "正在处理…" : memory.status === "ACTIVE" ? "确认停用" : "确认重新启用"}</button></form> : null}
|
||
{mode === "DELETE" ? <form className="memory-management__form memory-management__form--delete" onSubmit={(event) => void submitDelete(event)}><p>删除受理后会立即停止召回并使索引失效,后台随后永久清理正文和向量;该操作不能通过修改接口撤销。</p><label><span>删除原因</span><input required autoFocus maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} /></label><label className="consent-confirm"><input type="checkbox" checked={deleteConfirmed} onChange={(event) => setDeleteConfirmed(event.target.checked)} /><span>我已核对来源和家庭要求,确认永久删除这条记忆。</span></label><button type="submit" className="button-danger" disabled={actionBusy || !deleteConfirmed || !reason.trim()}>{actionBusy ? "正在受理…" : "确认永久删除"}</button></form> : null}
|
||
</> : !canReview ? <p className="workspace__readonly-note">当前家庭角色只能查看记忆和来源状态,不能修订、启停或删除。</p> : null}
|
||
</section>;
|
||
}
|
||
|
||
function SharingPanel({ subjects, selectedID, onSelect, members, stories, conversations, media, grants, issued, onDismissIssued, actionBusy, onCreate, onRevoke }: {
|
||
subjects: Subject[]; selectedID?: string; onSelect: (id: string) => void; members: HouseholdMember[]; stories: Story[]; conversations: ConversationSummary[];
|
||
media?: ConversationMedia; grants: SharingGrant[]; issued?: { id: string; token?: string }; onDismissIssued: () => void; actionBusy: boolean;
|
||
onCreate: (input: SharingGrantInput) => void;
|
||
onRevoke: (grant: SharingGrant) => Promise<void>;
|
||
}) {
|
||
const [resourceType, setResourceType] = useState<"STORY" | "TRANSCRIPT" | "RECORDING">("STORY");
|
||
const [resourceID, setResourceID] = useState("");
|
||
const [granteeID, setGranteeID] = useState("");
|
||
const [permission, setPermission] = useState<"VIEW_TEXT" | "LISTEN" | "DOWNLOAD" | "EDIT">("VIEW_TEXT");
|
||
const [purpose, setPurpose] = useState("");
|
||
const [validHours, setValidHours] = useState(24);
|
||
const [loginRequired, setLoginRequired] = useState(true);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const activeMembers = members.filter((item) => item.status === "active");
|
||
const options = resourceType === "STORY" ? stories.map((item) => ({ id: item.id, label: item.title })) : resourceType === "TRANSCRIPT" ? conversations.map((item) => ({ id: item.id, label: `${formatDate(item.created_at)} · ${conversationStatusLabel(item.status)}` })) : (media?.segments ?? []).map((item) => ({ id: item.id, label: `${item.track === "near" ? "长者" : "智能体"}轨道 #${item.sequence_no}` }));
|
||
useEffect(() => { setResourceID(options[0]?.id ?? ""); }, [resourceType, stories, conversations, media]);
|
||
useEffect(() => { setGranteeID((current) => current && activeMembers.some((item) => item.user_id === current) ? current : activeMembers[0]?.user_id ?? ""); }, [members]);
|
||
useEffect(() => { if (issued) setPurpose(""); }, [issued]);
|
||
function changeType(value: "STORY" | "TRANSCRIPT" | "RECORDING") { setResourceType(value); setPermission(value === "RECORDING" ? "LISTEN" : "VIEW_TEXT"); }
|
||
function submit(event: FormEvent) { event.preventDefault(); if (!resourceID || !granteeID || !purpose.trim()) return; onCreate({ resource_type: resourceType, resource_id: resourceID, grantee_user_id: granteeID, permission, purpose: purpose.trim(), expires_at: new Date(Date.now() + validHours * 3600000).toISOString(), login_required: permission === "EDIT" ? true : loginRequired }); }
|
||
const active = grants.filter((item) => !item.revoked_at && new Date(item.expires_at).getTime() > Date.now());
|
||
return <div className="privacy-layout">
|
||
<div className="subject-list-toolbar"><div><strong>有效分享</strong><span>按具体资源和权限展示,创建操作不会占用列表空间。</span></div><button type="button" onClick={() => setCreateOpen(true)}><PlusOutlined /> 新建分享</button></div>
|
||
{createOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setCreateOpen(false); }}><form className="workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby="create-share-title" onSubmit={(event) => { void submit(event); setCreateOpen(false); }}><header><div><h3 id="create-share-title">新建资源分享</h3><p>文字、录音和编辑权限彼此独立。</p></div><button type="button" aria-label="关闭" onClick={() => setCreateOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body workspace-dialog__grid"><label><span>长者</span><select value={selectedID ?? ""} onChange={(event) => onSelect(event.target.value)}>{subjects.map((item) => <option key={item.id} value={item.id}>{item.preferred_name}</option>)}</select></label><label><span>资源类型</span><select value={resourceType} onChange={(event) => changeType(event.target.value as "STORY" | "TRANSCRIPT" | "RECORDING")}><option value="STORY">故事</option><option value="TRANSCRIPT">通话文字</option><option value="RECORDING">录音分片</option></select></label><label><span>具体资源</span><select value={resourceID} onChange={(event) => setResourceID(event.target.value)} required>{options.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}</select></label><label><span>接收人</span><select value={granteeID} onChange={(event) => setGranteeID(event.target.value)} required>{activeMembers.map((item) => <option key={item.id} value={item.user_id}>{item.display_name} · {roleLabel(item.role)}</option>)}</select></label><label><span>权限</span><select value={permission} onChange={(event) => { const value=event.target.value as typeof permission;setPermission(value);if(value==="EDIT")setLoginRequired(true); }}>{resourceType === "STORY" ? <><option value="VIEW_TEXT">查看文字</option><option value="EDIT">编辑故事</option></> : resourceType === "TRANSCRIPT" ? <option value="VIEW_TEXT">查看文字</option> : <><option value="LISTEN">收听</option><option value="DOWNLOAD">下载</option></>}</select></label><label><span>有效小时</span><input type="number" min={1} max={720} value={validHours} onChange={(event) => setValidHours(Number(event.target.value))} /></label><label className="workspace-dialog__wide"><span>分享用途</span><input value={purpose} maxLength={500} onChange={(event) => setPurpose(event.target.value)} placeholder="说明为什么分享该具体资源" required /></label><label className="consent-confirm workspace-dialog__wide"><input type="checkbox" checked={loginRequired} disabled={permission === "EDIT"} onChange={(event) => setLoginRequired(event.target.checked)} /><span>仅允许指定接收人登录后访问;取消勾选将生成匿名口令。</span></label></div><footer><button type="button" className="button-secondary" onClick={() => setCreateOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !resourceID || !granteeID || !purpose.trim()}>下一步确认</button></footer></form></div> : null}
|
||
{issued ? <div className="workspace-dialog-backdrop" role="presentation"><div className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="share-created-title"><header><div><h3 id="share-created-title">{issued.token ? "匿名访问口令只显示一次" : "登录分享已创建"}</h3><p>请通过受控渠道转交访问地址和凭据。</p></div></header><div className="workspace-dialog__body"><p>地址:{issued.token ? `/api/v1/public/shares/${issued.id}` : `/api/v1/sharing-grants/${issued.id}/resource`}</p>{issued.token ? <code>{issued.token}</code> : null}<p>{issued.token ? "地址和口令请分开发送。" : "访问时必须使用指定接收人的登录身份。"}</p></div><footer><button type="button" onClick={onDismissIssued}>我已安全保存</button></footer></div></div> : null}
|
||
<section className="privacy-exports"><div className="section-title"><h4>有效分享</h4><span>{active.length}</span></div>{active.length ? active.map((grant) => <article className="data-row" key={grant.id}><div><strong>{sharingResourceLabel(grant.resource_type)} · {sharingPermissionLabel(grant.permission)}</strong><p>{grant.purpose} · {grant.login_required ? "登录接收人" : "匿名口令"} · {formatDate(grant.expires_at)}到期</p></div><button type="button" className="button-small button-danger" disabled={actionBusy} onClick={() => void onRevoke(grant)}>撤销</button></article>) : <p className="empty-copy">当前长者没有有效分享。</p>}</section>
|
||
</div>;
|
||
}
|
||
|
||
function PrivacyPanel({ subjects, selectedID, onSelect, requests, exports, consents, loading, canManage, actionBusy, onCreateAccess, onCreateCorrection, onWithdrawConsents, onCreateDelete, onCreateExport, onDownloadExport }: {
|
||
subjects: Subject[]; selectedID?: string; onSelect: (id: string) => void; requests: PrivacyRequest[]; exports: ExportTask[]; consents: Consent[];
|
||
loading: boolean; canManage: boolean; actionBusy: boolean; onCreateAccess: () => Promise<void>; onCreateCorrection: (corrections: { preferred_name?: string; language?: string; timezone?: string }) => Promise<boolean>; onWithdrawConsents: (types: Consent["type"][]) => Promise<boolean>; onCreateDelete: (reason: string, confirmation: string) => Promise<boolean>; onCreateExport: () => Promise<void>; onDownloadExport: (task: ExportTask) => void;
|
||
}) {
|
||
const selectedSubject = subjects.find((subject) => subject.id === selectedID);
|
||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||
const [deleteReason, setDeleteReason] = useState("");
|
||
const [deleteConfirmation, setDeleteConfirmation] = useState("");
|
||
const [correctionOpen, setCorrectionOpen] = useState(false);
|
||
const [correctionName, setCorrectionName] = useState("");
|
||
const [correctionLanguage, setCorrectionLanguage] = useState("");
|
||
const [correctionTimezone, setCorrectionTimezone] = useState("");
|
||
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
|
||
const [withdrawalTypes, setWithdrawalTypes] = useState<Consent["type"][]>([]);
|
||
const effectiveConsentTypes = Array.from(new Set(consents.filter((item) => item.effective).map((item) => item.type)));
|
||
|
||
useEffect(() => {
|
||
setDeleteOpen(false);
|
||
setDeleteReason("");
|
||
setDeleteConfirmation("");
|
||
setCorrectionOpen(false);
|
||
setWithdrawalOpen(false);
|
||
setWithdrawalTypes([]);
|
||
}, [selectedID]);
|
||
|
||
async function submitDelete(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!selectedSubject || !deleteReason.trim() || deleteConfirmation.trim() !== selectedSubject.preferred_name) return;
|
||
const completed = await onCreateDelete(deleteReason.trim(), deleteConfirmation.trim());
|
||
if (completed) {
|
||
setDeleteOpen(false);
|
||
setDeleteReason("");
|
||
setDeleteConfirmation("");
|
||
}
|
||
}
|
||
|
||
async function submitCorrection(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!selectedSubject || !correctionName.trim() || !correctionLanguage.trim() || !correctionTimezone.trim()) return;
|
||
const completed = await onCreateCorrection({ preferred_name: correctionName.trim(), language: correctionLanguage.trim(), timezone: correctionTimezone.trim() });
|
||
if (completed) setCorrectionOpen(false);
|
||
}
|
||
|
||
async function submitWithdrawal(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (withdrawalTypes.length === 0) return;
|
||
const completed = await onWithdrawConsents(withdrawalTypes);
|
||
if (completed) { setWithdrawalOpen(false); setWithdrawalTypes([]); }
|
||
}
|
||
|
||
return <div className="privacy-layout">
|
||
<div className="privacy-toolbar">
|
||
<label><span>长者</span><select value={selectedID ?? ""} onChange={(event) => onSelect(event.target.value)}>{subjects.map((subject) => <option value={subject.id} key={subject.id}>{subject.preferred_name}</option>)}</select></label>
|
||
<div><strong>数据访问申请</strong><p>后台按固定长者边界盘点资料数量,不在列表或日志中回传正文。</p></div>
|
||
{canManage ? <div className="privacy-actions"><button type="button" disabled={actionBusy || !selectedID} onClick={() => void onCreateAccess()}>提交全量盘点</button><button type="button" className="button-secondary" disabled={actionBusy || !selectedSubject} onClick={() => { if (!selectedSubject) return; setCorrectionName(selectedSubject.preferred_name); setCorrectionLanguage(selectedSubject.language); setCorrectionTimezone(selectedSubject.timezone); setCorrectionOpen(true); }}>资料更正</button><button type="button" className="button-secondary" disabled={actionBusy || effectiveConsentTypes.length === 0} onClick={() => { setWithdrawalTypes([]); setWithdrawalOpen(true); }}>批量撤回授权</button><button type="button" className="button-secondary" disabled={actionBusy || !selectedID} onClick={() => void onCreateExport()}>生成私有导出</button><button type="button" className="button-danger" aria-expanded={deleteOpen} disabled={actionBusy || !selectedSubject} onClick={() => { setDeleteOpen(true); setDeleteReason(""); setDeleteConfirmation(""); }}>永久删除主体</button></div> : <span className="permission">只读</span>}
|
||
</div>
|
||
{deleteOpen && selectedSubject ? <div className="workspace-dialog-backdrop" role="presentation"><form className="privacy-delete-confirmation workspace-dialog workspace-dialog--wide" aria-label="永久删除主体确认" onSubmit={(event) => void submitDelete(event)}>
|
||
<div className="privacy-delete-confirmation__head"><div><p className="eyebrow">IRREVERSIBLE PRIVACY ACTION</p><h4>永久删除“{selectedSubject.preferred_name}”</h4></div><span className="status failed">不可逆</span></div>
|
||
<p>提交后会立即冻结该长者,撤回授权并停止新的内容留存;PostgreSQL、缓存、对象存储、向量索引、供应商引用和私有导出将进入可重试删除对账。存在活动通话时服务端会拒绝受理。</p>
|
||
<label><span>删除原因</span><textarea required autoFocus maxLength={500} value={deleteReason} onChange={(event) => setDeleteReason(event.target.value)} placeholder="说明删除依据;服务端只保存SHA-256摘要,不持久化原因正文" /></label>
|
||
<label><span>输入长者称呼“{selectedSubject.preferred_name}”确认</span><input required maxLength={120} autoComplete="off" value={deleteConfirmation} onChange={(event) => setDeleteConfirmation(event.target.value)} aria-invalid={Boolean(deleteConfirmation) && deleteConfirmation.trim() !== selectedSubject.preferred_name} /></label>
|
||
{deleteConfirmation && deleteConfirmation.trim() !== selectedSubject.preferred_name ? <p className="privacy-delete-confirmation__error" role="alert">称呼必须与“{selectedSubject.preferred_name}”完全一致。</p> : null}
|
||
<div className="privacy-delete-confirmation__actions"><button type="button" className="button-secondary" disabled={actionBusy} onClick={() => { setDeleteOpen(false); setDeleteReason(""); setDeleteConfirmation(""); }}>取消</button><button type="submit" className="button-danger" disabled={actionBusy || !deleteReason.trim() || deleteConfirmation.trim() !== selectedSubject.preferred_name}>{actionBusy ? "正在执行删除对账…" : "确认永久删除"}</button></div>
|
||
</form></div> : null}
|
||
{correctionOpen && selectedSubject ? <div className="workspace-dialog-backdrop" role="presentation"><form className="workspace-dialog" aria-label="资料更正申请" onSubmit={(event) => void submitCorrection(event)}><header><div><h3>提交资料更正</h3><p>更正会生成新的档案版本,不改写历史通话快照。</p></div><button type="button" aria-label="关闭" onClick={() => setCorrectionOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>称呼</span><input autoFocus required maxLength={120} value={correctionName} onChange={(event) => setCorrectionName(event.target.value)} /></label><label><span>语言</span><input required minLength={2} maxLength={20} value={correctionLanguage} onChange={(event) => setCorrectionLanguage(event.target.value)} /></label><label><span>时区</span><input required maxLength={100} value={correctionTimezone} onChange={(event) => setCorrectionTimezone(event.target.value)} placeholder="Asia/Shanghai" /></label><p className="workspace-dialog__notice">隐私请求和审计只记录更正字段名称,不复制档案正文。</p></div><footer><button type="button" className="button-secondary" onClick={() => setCorrectionOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !correctionName.trim() || !correctionLanguage.trim() || !correctionTimezone.trim()}>{actionBusy ? "正在更正…" : "确认更正"}</button></footer></form></div> : null}
|
||
{withdrawalOpen ? <div className="workspace-dialog-backdrop" role="presentation"><form className="workspace-dialog" aria-label="批量撤回授权" onSubmit={(event) => void submitWithdrawal(event)}><header><div><h3>批量撤回授权</h3><p>撤回立即生效,依赖这些授权的活动通话会安全停止。</p></div><button type="button" aria-label="关闭" onClick={() => setWithdrawalOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><fieldset className="privacy-consent-selection"><legend>选择要撤回的当前有效授权</legend>{effectiveConsentTypes.map((type) => <label key={type}><input type="checkbox" checked={withdrawalTypes.includes(type)} onChange={(event) => setWithdrawalTypes(event.target.checked ? [...withdrawalTypes, type] : withdrawalTypes.filter((item) => item !== type))} /><span>{consentTypeLabel(type)}</span></label>)}</fieldset><p className="workspace-dialog__notice">个性化记忆授权撤回后,已有记忆会停用并清除可召回向量。</p></div><footer><button type="button" className="button-secondary" onClick={() => setWithdrawalOpen(false)}>取消</button><button type="submit" className="button-danger" disabled={actionBusy || withdrawalTypes.length === 0}>{actionBusy ? "正在撤回…" : `确认撤回 ${withdrawalTypes.length} 项`}</button></footer></form></div> : null}
|
||
{loading && !requests.length ? <StatePanel title="正在读取隐私请求" detail="同步后台子任务和盘点结果…" /> : requests.length ? <div className="privacy-grid">{requests.map((request) => {
|
||
const counts = (request.result_summary.inventory_counts ?? {}) as Record<string, number>;
|
||
return <article className="privacy-card" key={request.id}>
|
||
<div className="privacy-card__head"><div><strong>{privacyRequestTypeLabel(request.request_type)}</strong><span>{formatDate(request.created_at)} · v{request.version}</span></div><span className={`tag tag--${request.status.toLowerCase()}`}>{privacyStatusLabel(request.status)}</span></div>
|
||
<div className="privacy-counts">{Object.entries(counts).map(([category, count]) => <Info key={category} label={privacyCategoryLabel(category)} value={String(count)} />)}</div>
|
||
<p>{privacyRequestSummary(request, counts)}</p>
|
||
{request.request_type === "DELETE" ? <div className="privacy-counts">{request.subtasks.map((item) => <Info key={item.id} label={privacySubsystemLabel(item.subsystem)} value={item.status === "SKIPPED" ? "已核验/无需处理" : privacySubtaskStatusLabel(item.status)} />)}</div> : null}
|
||
</article>;
|
||
})}</div> : <StatePanel title="暂无隐私请求" detail="家庭所有者可以提交当前长者的数据访问盘点。" />}
|
||
<section className="privacy-exports"><div className="section-title"><h4>私有导出</h4><span>{exports.length}</span></div>{exports.length ? exports.map((task) => <article className="data-row" key={task.id}><div><strong>{exportStatusLabel(task.status)}</strong><p>{formatDate(task.created_at)}{task.object_size !== undefined ? ` · ${Math.max(1, Math.ceil(Number(task.object_size) / 1024))} KB` : ""}{task.expires_at ? ` · ${formatDate(task.expires_at)}过期` : ""}</p></div><div className="row-actions"><span className={`tag tag--${task.status.toLowerCase()}`}>{exportStatusLabel(task.status)}</span>{task.status === "READY" ? <button type="button" className="button-small" disabled={actionBusy} onClick={() => onDownloadExport(task)}>获取短期下载</button> : null}</div></article>) : <p className="empty-copy">尚未生成私有导出文件。</p>}</section>
|
||
</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>; }
|
||
type RecordingQueueView = { pendingSegments: number; failedSegments: number; lastFailureCode?: string; lastFailureAt?: string; observedAt?: string };
|
||
function recordingQueueFromTwin(twin: DeviceTwin): RecordingQueueView | undefined {
|
||
const reported = asRecord(twin.reported_config);
|
||
const heartbeat = asRecord(reported?.heartbeat);
|
||
const telemetry = asRecord(reported?.telemetry);
|
||
const telemetryStatus = asRecord(telemetry?.status);
|
||
const candidates = [
|
||
queueView(asRecord(heartbeat?.recording_queue), stringValue(heartbeat?.device_time)),
|
||
queueView(asRecord(telemetryStatus?.recording_queue), stringValue(telemetry?.occurred_at))
|
||
].filter((item): item is RecordingQueueView => Boolean(item));
|
||
return candidates.sort((left, right) => dateValue(right.observedAt) - dateValue(left.observedAt))[0];
|
||
}
|
||
function queueView(value: Record<string, unknown> | undefined, observedAt?: string): RecordingQueueView | undefined {
|
||
if (!value || typeof value.pending_segments !== "number" || typeof value.failed_segments !== "number") return undefined;
|
||
return { pendingSegments: value.pending_segments, failedSegments: value.failed_segments, lastFailureCode: stringValue(value.last_failure_code), lastFailureAt: stringValue(value.last_failure_at), observedAt };
|
||
}
|
||
function asRecord(value: unknown): Record<string, unknown> | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined; }
|
||
function deviceSpeakerVolume(config: Record<string, unknown> | undefined) {
|
||
const speaker = asRecord(config?.volume)?.speaker;
|
||
return typeof speaker === "number" && Number.isInteger(speaker) && speaker >= 0 && speaker <= 100 ? speaker : undefined;
|
||
}
|
||
function stringValue(value: unknown) { return typeof value === "string" && value ? value : undefined; }
|
||
function decodeStandardBase64(value: string) {
|
||
if (!value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return undefined;
|
||
try { const binary = window.atob(value); return Uint8Array.from(binary, (character) => character.charCodeAt(0)); } catch { return undefined; }
|
||
}
|
||
function dateValue(value?: string) { const parsed = value ? Date.parse(value) : Number.NaN; return Number.isFinite(parsed) ? parsed : 0; }
|
||
function uploadQueueSummary(queue: RecordingQueueView) { return queue.failedSegments ? `${queue.failedSegments}个失败,${queue.pendingSegments}个待上传` : queue.pendingSegments ? `${queue.pendingSegments}个待上传` : "无积压"; }
|
||
function familyControlReady(draft: FamilyControlDraft) {
|
||
if (draft.kind === "member_remove" || draft.kind === "ownership_transfer") return draft.confirmation === draft.item.display_name;
|
||
if (draft.kind === "member_role_change") return draft.targetRole !== draft.item.role && draft.acknowledged;
|
||
return draft.acknowledged;
|
||
}
|
||
function familyControlDangerous(kind: FamilyControlDraft["kind"]) { return kind !== "firmware_pause" && kind !== "device_diagnostics" && kind !== "privacy_access_request" && kind !== "recording_play_address"; }
|
||
function familyControlToneLabel(kind: FamilyControlDraft["kind"]) { return kind === "household_create" ? "新家庭所有权" : kind === "invitation_accept" ? "建立家庭关系" : kind === "subject_create" ? "长期个人档案" : kind === "fact_create" ? "长期事实候选" : kind === "subject_update" ? "档案版本变更" : kind === "device_twin_update" ? "终端期望配置" : kind === "consent_grant" ? "扩大数据处理范围" : kind === "firmware_key_register" ? "固件信任根变更" : kind === "firmware_key_revoke" ? "固件信任根吊销" : kind === "firmware_release_publish" ? "固件制品写入" : kind === "firmware_release_resume" ? "固件对象续传" : kind === "firmware_release_pause_distribution" ? "固件版本停发" : kind === "firmware_release_resume_distribution" ? "固件版本恢复" : kind === "firmware_release_retire" ? "固件版本退役" : kind === "firmware_rollout_start" ? "多设备版本变更" : kind === "device_diagnostics" ? "诊断命令" : kind === "device_claim" ? "一次性设备绑定" : kind === "invitation_create" ? "一次性成员凭据" : kind === "firmware_pause" ? "批次控制" : kind === "privacy_access_request" ? "隐私盘点" : kind === "recording_access_grant" ? "敏感录音授权" : kind === "recording_play_address" ? "敏感录音播放" : kind === "recording_download_address" ? "敏感录音下载" : kind === "sharing_grant_create" ? "敏感资源分享" : kind === "privacy_export_request" || kind === "privacy_export_download" ? "敏感数据导出" : "高影响操作"; }
|
||
function familyControlTitle(kind: FamilyControlDraft["kind"]) { return ({ household_create: "创建新的家庭空间", invitation_accept: "接受家庭成员邀请", subject_create: "创建长者长期档案", fact_create: "登记稳定事实候选", subject_update: "保存长者档案新版本", device_twin_update: "修改设备扬声器期望配置", consent_grant: "新增长者分项授权", firmware_key_register: "登记固件可信签名公钥", firmware_key_revoke: "吊销固件可信签名公钥", firmware_release_publish: "上传并验证固件制品", firmware_release_resume: "恢复未完成固件上传", firmware_release_pause_distribution: "暂停固件版本分发", firmware_release_resume_distribution: "恢复固件版本分发", firmware_release_retire: "退役固件版本", firmware_rollout_start: "启动固件灰度批次", consent_withdraw: "撤回长者分项授权", member_remove: "移出家庭成员", ownership_transfer: "转移家庭所有权", member_role_change: "调整家庭成员角色", invitation_create: "生成家庭成员邀请凭据", invitation_revoke: "撤销成员邀请", fact_confirm: "确认稳定事实", recording_access_grant: "授予长者录音访问能力", recording_access_revoke: "撤销录音访问授权", recording_play_address: "申请本次录音播放地址", recording_download_address: "申请本次录音下载地址", privacy_access_request: "提交隐私数据全量盘点", privacy_export_request: "生成私有数据导出", privacy_export_download: "申请私有导出下载地址", device_claim: "使用设备屏幕绑定码", device_diagnostics: "下发设备诊断", firmware_pause: "暂停固件发布批次", firmware_rollback: "创建固件回滚批次", sharing_grant_create: "创建具体资源分享", sharing_revoke: "撤销资源分享" } as const)[kind]; }
|
||
function familyControlSubmitLabel(kind: FamilyControlDraft["kind"]) { return ({ household_create: "确认创建并成为所有者", invitation_accept: "确认加入该家庭", subject_create: "确认创建长期档案", fact_create: "确认登记待核对事实", subject_update: "确认保存档案新版本", device_twin_update: "确认下发期望配置", consent_grant: "确认新增分项授权", firmware_key_register: "确认登记可信公钥", firmware_key_revoke: "确认吊销可信公钥", firmware_release_publish: "确认上传并验证制品", firmware_release_resume: "确认续传并重新校验", firmware_release_pause_distribution: "确认暂停版本分发", firmware_release_resume_distribution: "确认恢复版本分发", firmware_release_retire: "确认退役固件版本", firmware_rollout_start: "确认启动灰度批次", consent_withdraw: "确认撤回授权", member_remove: "确认移出成员", ownership_transfer: "确认转移所有权", member_role_change: "确认调整角色", invitation_create: "确认生成成员邀请", invitation_revoke: "确认撤销邀请", fact_confirm: "确认该事实", recording_access_grant: "确认授予录音能力", recording_access_revoke: "确认撤销录音授权", recording_play_address: "确认并获取播放地址", recording_download_address: "确认并下载录音", privacy_access_request: "确认提交盘点", privacy_export_request: "确认生成私有导出", privacy_export_download: "确认并下载私有导出", device_claim: "确认绑定该设备", device_diagnostics: "确认下发诊断", firmware_pause: "确认暂停批次", firmware_rollback: "确认创建回滚", sharing_grant_create: "确认创建资源分享", sharing_revoke: "确认撤销分享" } as const)[kind]; }
|
||
function familyControlAcknowledgement(draft: FamilyControlDraft) {
|
||
if (draft.kind === "member_role_change") return `我确认把${draft.item.display_name}从${roleLabel(draft.item.role)}调整为${roleLabel(draft.targetRole)};${memberRoleImpact(draft.targetRole)}`;
|
||
if (draft.kind === "subject_update") return `我已逐项核对从档案v${draft.item.version}到v${draft.item.version + 1}的变化;新版本只供后续新通话冻结使用,当前和历史通话继续保留原快照,本操作不会新增或扩大任何分项授权`;
|
||
if (draft.kind === "device_twin_update") return `我确认把设备扬声器期望值从${draft.previousVolume === undefined ? "未设置" : `${draft.previousVolume}%`}调整为${draft.speakerVolume}%;离线设备可能稍后领取,页面在设备重新上报前不会把期望值误报为实际已应用`;
|
||
if (draft.kind === "consent_grant") return `我已向长者或合法授权人说明“${consentLabel(draft.input.type)}”的数据范围、用途和单独撤回方式,并确认政策版本、告知文本与留存证据准确;${consentImpactLabel(draft.input.type)}`;
|
||
if (draft.kind === "firmware_key_register") return `我已通过受控渠道核对密钥编号“${draft.keyID}”和SHA-256指纹${draft.fingerprint};提交内容是32字节Ed25519公钥而不是私钥,并理解密钥编号一旦登记就与该指纹永久绑定,轮换必须使用新编号`;
|
||
if (draft.kind === "firmware_key_revoke") return `我确认吊销密钥“${draft.item.key_id}”;该操作不会删除历史制品或重算既有签名,但会永久阻止该密钥用于新制品完成校验和新灰度,吊销后不能用同一编号重新激活`;
|
||
if (draft.kind === "firmware_release_publish") return `我已核对设备型号、主板、版本、文件大小和SHA-256,并确认64字节Ed25519签名由密钥“${draft.input.signing_key_id}”对页面所列firmware-manifest-v1清单离线生成;确认后浏览器会把制品直传私有对象存储,只有大小、摘要和签名全部通过才进入就绪状态`;
|
||
if (draft.kind === "firmware_release_resume") return `我确认重新选择的本地文件与版本“${draft.item.version}”已冻结清单的大小和SHA-256完全一致;平台将重新签发15分钟上传地址、覆盖同一私有对象并再次校验大小、摘要和原Ed25519签名,校验成功前不会进入就绪状态`;
|
||
if (draft.kind === "firmware_release_pause_distribution") return `我确认立即暂停版本“${draft.item.version}”的分发;新灰度和新下载地址将被阻断,${draft.runningRollouts}个关联运行中批次会转为暂停且未投递命令被取消,已经取得下载地址或进入安装的设备仍需继续观察`;
|
||
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];
|
||
}
|
||
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 === "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;保存后需等待设备领取和上报。</>;
|
||
if (draft.kind === "consent_grant") return <>长者 <strong>{draft.subjectName}</strong>({draft.subjectID})· 授权项目 <strong>{consentLabel(draft.input.type)}</strong> · 范围 后续新通话 · 政策版本 <strong>{draft.input.policy_version}</strong> · 告知文本 “{draft.notice}” · 告知摘要 {draft.input.notice_digest.slice(0, 12)}… · 证据 {draft.input.evidence_ref || "未登记"} · 有效期 <strong>{draft.input.expires_at ? formatDate(draft.input.expires_at) : "持续有效,直至单独撤回"}</strong>。{consentImpactLabel(draft.input.type)}服务端会拒绝同类重复有效授权并创建不可变授权快照。</>;
|
||
if (draft.kind === "firmware_key_register") return <>家庭 <strong>{draft.household.name}</strong>({draft.household.id})· 密钥编号 <strong>{draft.keyID}</strong> · Ed25519公钥长度 32字节 · SHA-256指纹 <strong>{draft.fingerprint}</strong>。服务端提交时重新校验固件管理能力和公钥编码;新编号与指纹永久绑定,相同编号仅允许完全相同活动公钥幂等登记,其他公钥或已吊销编号均返回冲突。</>;
|
||
if (draft.kind === "firmware_key_revoke") return <>家庭 <strong>{draft.household.name}</strong>({draft.household.id})· 密钥编号 <strong>{draft.item.key_id}</strong> · SHA-256指纹 <strong>{draft.item.fingerprint_sha256}</strong> · 当前活动 · 上传中版本 {draft.item.uploading_releases} · 已验证版本 {draft.item.ready_releases} · 运行中灰度 {draft.item.running_rollouts}。服务端提交时锁定密钥并重新统计引用;存在上传中版本或运行中灰度时拒绝吊销,成功后历史已验证版本保留但不能再创建新灰度。</>;
|
||
if (draft.kind === "firmware_release_publish") return <>家庭 <strong>{draft.household.name}</strong>({draft.household.id})· 固件 <strong>{draft.input.model} / {draft.input.hardware_revision || "全部主板"} / {draft.input.version}</strong> · 文件 {draft.file.name}({formatBytes(draft.file.size)})· SHA-256 <strong>{draft.input.expected_digest}</strong> · 签名密钥 {draft.input.signing_key_id} · 最低安全基线 {draft.input.minimum_security_version || "未声明"} · 发布说明 {draft.input.release_notes || "未填写"}。签名清单固定为:firmware-manifest-v1;model={draft.input.model};hardware_revision={draft.input.hardware_revision || ""};version={draft.input.version};sha256={draft.input.expected_digest};size={draft.input.expected_size}。提交后先创建15分钟上传地址,浏览器直传原始文件,服务端再流式核对对象大小、摘要和Ed25519签名,全部通过后才标记为就绪。</>;
|
||
if (draft.kind === "firmware_release_resume") return <>家庭 <strong>{draft.household.name}</strong>({draft.household.id})· 未完成版本 <strong>{draft.item.model} / {draft.item.hardware_revision || "全部主板"} / {draft.item.version}</strong>({draft.item.id})· 当前状态 {firmwareReleaseStatusLabel(draft.item.status)} · 原记录 {formatBytes(draft.item.expected_size)} / SHA-256 <strong>{draft.item.expected_digest}</strong> · 本地文件 {draft.file.name} / {formatBytes(draft.file.size)}。页面已确认大小与摘要一致;提交时服务端仍要求版本处于上传中,再签发15分钟私有PUT地址并重复执行对象和签名校验。</>;
|
||
if (draft.kind === "firmware_release_pause_distribution") return <>家庭 <strong>{draft.household.name}</strong>({draft.household.id})· 已验证版本 <strong>{draft.item.model} / {draft.item.hardware_revision || "全部主板"} / {draft.item.version}</strong>({draft.item.id})· SHA-256 <strong>{draft.item.expected_digest}</strong> · 签名密钥 {draft.item.signing_key_id} · 关联运行中灰度 <strong>{draft.runningRollouts}个</strong>。服务端锁定版本后转为暂停,并在同一事务暂停关联运行批次、取消尚未投递的设备命令。</>;
|
||
if (draft.kind === "firmware_release_resume_distribution") return <>家庭 <strong>{draft.household.name}</strong>({draft.household.id})· 已暂停版本 <strong>{draft.item.model} / {draft.item.hardware_revision || "全部主板"} / {draft.item.version}</strong>({draft.item.id})· SHA-256 <strong>{draft.item.expected_digest}</strong> · 签名密钥 {draft.item.signing_key_id}。服务端会重新锁定版本并核验签名密钥仍为活动状态,只恢复版本状态,不恢复任何旧批次。</>;
|
||
if (draft.kind === "firmware_release_retire") return <>家庭 <strong>{draft.household.name}</strong>({draft.household.id})· 固件版本 <strong>{draft.item.model} / {draft.item.hardware_revision || "全部主板"} / {draft.item.version}</strong>({draft.item.id})· 当前状态 {firmwareReleaseStatusLabel(draft.item.status)} · {formatBytes(draft.item.expected_size)} · SHA-256 <strong>{draft.item.expected_digest}</strong> · 签名密钥 {draft.item.signing_key_id}。{draft.item.status === "UPLOADING" ? "服务端使用条件更新与完成校验互斥,并尝试清理未完成对象。" : "服务端会拒绝仍被运行中批次引用的版本;已验证对象和历史记录不会被删除。"}</>;
|
||
if (draft.kind === "firmware_rollout_start") return <>家庭 <strong>{draft.household.name}</strong>({draft.household.id})· 批次 <strong>{draft.name}</strong> · 已验证固件 <strong>{draft.release.model} / {draft.release.hardware_revision || "全部主板"} / {draft.release.version}</strong>({draft.release.id},摘要 {draft.release.expected_digest.slice(0, 12)}…)· 灰度比例 <strong>{draft.percentage}%</strong> · 候选设备 {draft.devices.map((device) => `${device.model}(${device.id},当前${device.firmware_version || "版本未上报"})`).join("、")}。服务端提交时重新校验固件仍为就绪、设备仍兼容且属于当前家庭,再按公开编号哈希确定实际命中设备并创建24小时可靠升级命令。</>;
|
||
if (draft.kind === "consent_withdraw") return <>长者 <strong>{draft.subjectName}</strong>({draft.subjectID})· 授权 <strong>{consentLabel(draft.item.type)}</strong> · 授权编号 {draft.item.id} · 政策版本 {draft.item.policy_version} · 记录版本 v{draft.item.version}。{draft.item.type === "personalized_memory" ? "现有长期记忆和检索索引将立即停用。" : "后续新通话将不再使用该授权,依法保留的数据仍按独立删除流程处理。"}</>;
|
||
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_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)}到期。该能力按长者生效,可用于有效期内当前及后续可访问的已验证录音;服务端会撤销该成员原有的同类有效授权后创建新授权。</>;
|
||
if (draft.kind === "recording_access_revoke") return <>长者 <strong>{draft.subjectName}</strong>({draft.subjectID})· 授权成员 <strong>{draft.granteeName}</strong>({draft.item.grantee_user_id})· {draft.item.permission === "PLAY" ? "播放" : "下载"}权限 · 用途 {draft.item.purpose} · 授权编号 {draft.item.id} · {formatDate(draft.item.granted_at)}授予 · {formatDate(draft.item.expires_at)}到期。</>;
|
||
if (draft.kind === "recording_play_address" || draft.kind === "recording_download_address") return <>长者 <strong>{draft.subjectName}</strong>({draft.subjectID})· 通话 {draft.conversationID} · 固定分片 <strong>{draft.item.track === "near" ? "近端(长者)" : "远端(智能体)"} #{draft.item.sequence_no}</strong>({draft.item.id})· {Math.round(draft.item.duration_ms / 100) / 10}秒 · {formatBytes(Number(draft.item.expected_size))} · 用途 {draft.purpose}。服务端提交时会重新核验当前授权和原录音授权,并只签发5分钟的{draft.kind === "recording_play_address" ? "浏览器播放" : "文件下载"}地址。</>;
|
||
if (draft.kind === "privacy_access_request" || draft.kind === "privacy_export_request") return <>长者 <strong>{draft.subjectName}</strong>({draft.subjectID})· 固定范围 {draft.categories.map(privacyCategoryLabel).join("、")}(共{draft.categories.length}类)。{draft.kind === "privacy_access_request" ? "盘点只生成分类数量、请求状态和子任务结果,不包含个人正文。" : "导出任务将由后台生成包含个人正文的私有JSON文件,对象和短期下载地址均有有效期。"}</>;
|
||
if (draft.kind === "privacy_export_download") return <>长者 <strong>{draft.subjectName}</strong>({draft.subjectID})· 固定导出任务 <strong>{draft.item.id}</strong> · {draft.categories.map(privacyCategoryLabel).join("、")}(共{draft.categories.length}类)· {draft.item.object_size !== undefined ? formatBytes(Number(draft.item.object_size)) : "大小待确认"} · {draft.item.expires_at ? `${formatDate(draft.item.expires_at)}对象过期` : "对象有效期待确认"}。提交时重新读取任务状态,只有仍为可下载时才签发短期地址。</>;
|
||
if (draft.kind === "device_claim") return <>家庭 <strong>{draft.household.name}</strong>({draft.household.id})· 长者 <strong>{draft.subjectName}</strong>({draft.subjectID})· 设备屏幕绑定码 {maskedSecret(draft.claimCode)}。平台不会生成或回显完整设备码;提交时使用该码查找未绑定、未过期且未撤销的设备,并建立唯一有效绑定。相同目标可安全重试,其他目标或已绑定设备会被拒绝。</>;
|
||
if (draft.kind === "device_diagnostics") return <>设备 <strong>{draft.item.model}</strong>({draft.item.id})· 长者 {draft.item.subject_name}({draft.item.subject_id})· {draft.item.hardware_revision || "硬件版本未上报"} / {draft.item.firmware_version || "固件版本未上报"} · {draft.presence?.online ? "当前在线" : "当前离线"} · 期望配置 v{draft.item.desired_revision} · 设备记录 v{draft.item.version}。诊断范围固定为音频、网络和存储。</>;
|
||
if (draft.kind === "firmware_pause") return <>固件批次 <strong>{draft.item.name}</strong>({draft.item.id})· 当前{firmwareRolloutStatusLabel(draft.item.status)} · 灰度 {draft.item.target_percentage}% · 目标 {draft.item.targeted} 台 · 成功 {draft.item.succeeded} 台 · 失败 {draft.item.failed} 台。</>;
|
||
if (draft.kind === "firmware_rollback") return <>原固件批次 <strong>{draft.item.name}</strong>({draft.item.id})· 当前{firmwareRolloutStatusLabel(draft.item.status)};回滚目标 <strong>{draft.release.model} / {draft.release.version}</strong>({draft.release.id})· {firmwareReleaseStatusLabel(draft.release.status)}。</>;
|
||
if (draft.kind === "sharing_grant_create") return <>长者 <strong>{draft.subjectName}</strong>({draft.subjectID})· 固定资源 <strong>{draft.resourceLabel}</strong>({draft.input.resource_id})· 接收成员 {draft.granteeName}({draft.input.grantee_user_id})· 能力 <strong>{sharingPermissionLabel(draft.input.permission)}</strong> · 用途 {draft.input.purpose} · {formatDate(draft.input.expires_at)}到期 · {draft.input.login_required ? "仅指定接收人登录后访问" : "生成只显示一次的匿名访问口令"}。</>;
|
||
return <>分享 <strong>{draft.item.id}</strong> · {sharingResourceLabel(draft.item.resource_type)} {draft.item.resource_id} · {sharingPermissionLabel(draft.item.permission)} · {draft.item.login_required ? "登录接收人" : "匿名口令"} · {formatDate(draft.item.expires_at)}到期 · 记录版本 v{draft.item.version}。</>;
|
||
}
|
||
function errorMessage(error: unknown) { return error instanceof Error ? error.message : "未知错误"; }
|
||
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 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 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) {
|
||
const changes: string[] = [];
|
||
if (input.preferred_name !== undefined && input.preferred_name !== subject.preferred_name) changes.push(`称呼“${subject.preferred_name}”→“${input.preferred_name}”`);
|
||
const profile = input.profile_data ?? subject.profile_data;
|
||
const nextBackground = profileString(profile, "background");
|
||
const currentBackground = profileString(subject.profile_data, "background");
|
||
if (nextBackground !== currentBackground) changes.push(`背景“${currentBackground || "未填写"}”→“${nextBackground || "未填写"}”`);
|
||
const nextInterests = profileList(profile, "interests");
|
||
const currentInterests = profileList(subject.profile_data, "interests");
|
||
if (JSON.stringify(nextInterests) !== JSON.stringify(currentInterests)) changes.push(`兴趣“${contextItemsLabel(currentInterests)}”→“${contextItemsLabel(nextInterests)}”`);
|
||
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 || "未填写"}”`);
|
||
const nextPace = profilePace(preferences);
|
||
const currentPace = profilePace(subject.conversation_preferences);
|
||
if (nextPace !== currentPace) changes.push(`节奏“${conversationPaceLabel(currentPace)}”→“${conversationPaceLabel(nextPace)}”`);
|
||
const nextTopics = profileList(preferences, "preferred_topics");
|
||
const currentTopics = profileList(subject.conversation_preferences, "preferred_topics");
|
||
if (JSON.stringify(nextTopics) !== JSON.stringify(currentTopics)) changes.push(`优先话题“${contextItemsLabel(currentTopics)}”→“${contextItemsLabel(nextTopics)}”`);
|
||
const nextAvoidedTopics = profileList(preferences, "avoided_topics");
|
||
const currentAvoidedTopics = profileList(subject.conversation_preferences, "avoided_topics");
|
||
if (JSON.stringify(nextAvoidedTopics) !== JSON.stringify(currentAvoidedTopics)) changes.push(`避免主动提及“${contextItemsLabel(currentAvoidedTopics)}”→“${contextItemsLabel(nextAvoidedTopics)}”`);
|
||
return changes.length ? changes : ["无有效变化"];
|
||
}
|
||
function formatDate(value: string) { return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); }
|
||
function statusLabel(value: string) { return ({ active: "启用", suspended: "暂停", deleting: "删除处理中" } as Record<string, string>)[value] ?? value; }
|
||
function factTypeLabel(value: string) { return ({ family_note: "家庭信息", preference: "个人偏好", life_event: "人生事件", health_context: "健康背景" } as Record<string, string>)[value] ?? value; }
|
||
function subjectProfileCompletion(subject: Subject) {
|
||
const values = [
|
||
subject.preferred_name,
|
||
profileString(subject.profile_data, "background"),
|
||
profileList(subject.profile_data, "interests").length,
|
||
profileString(subject.conversation_preferences, "conversation_goal"),
|
||
profileList(subject.conversation_preferences, "preferred_topics").length,
|
||
profileList(subject.conversation_preferences, "avoided_topics").length,
|
||
profilePace(subject.conversation_preferences)
|
||
];
|
||
return Math.round(values.filter(Boolean).length / values.length * 100);
|
||
}
|
||
function factStatusLabel(value: string) { return ({ pending: "等待核对", candidate: "候选", confirmed: "已确认", rejected: "已拒绝", superseded: "已替代" } as Record<string, string>)[value] ?? value; }
|
||
function factSourceLabel(value: string) { return ({ subject_statement: "长者陈述", family_entry: "家庭录入", model_candidate: "模型候选", imported: "外部导入" } as Record<string, string>)[value] ?? value; }
|
||
function factSensitivityLabel(value: string) { return ({ normal: "普通敏感级", sensitive: "敏感", high: "高敏感" } as Record<string, string>)[value] ?? value; }
|
||
const contentErrorTypeOptions: { value: ContentErrorType; label: string }[] = [
|
||
{ value: "PERSON_ERROR", label: "人物错误" },
|
||
{ value: "TIME_ERROR", label: "时间错误" },
|
||
{ value: "PLACE_ERROR", label: "地点错误" },
|
||
{ value: "RELATION_ERROR", label: "关系错误" },
|
||
{ value: "OVER_INFERENCE", label: "过度推断" },
|
||
{ value: "KEY_POINT_OMISSION", label: "遗漏重点" },
|
||
{ value: "DUPLICATE_CONTENT", label: "重复内容" },
|
||
{ value: "UNNATURAL_EXPRESSION", label: "表达不自然" },
|
||
{ value: "SENSITIVE_INFORMATION", label: "敏感信息" }
|
||
];
|
||
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; }
|
||
function conversationStatusLabel(value: string) { return ({ PREPARING: "准备中", ROOM_ISSUED: "房间已分配", STARTING_AGENT: "智能体启动中", ACTIVE: "通话中", STOPPING: "结束中", COMPLETED: "已完成", FAILED: "失败", EXPIRED: "已过期" } as Record<string, string>)[value] ?? value; }
|
||
function engineModeLabel(value: string) { return ({ s2s_pure: "端到端语音", s2s_hybrid: "端到端混合", cascade: "识别·理解·合成级联" } as Record<string, string>)[value] ?? value; }
|
||
function retentionModeLabel(value: string) { return ({ none: "不保存字幕和录音", transcript_only: "仅保存字幕", transcript_and_audio: "保存字幕和双轨录音" } as Record<string, string>)[value] ?? value; }
|
||
function conversationSourceLabel(value: string) { return ({ device: "设备", platform: "云端平台", provider: "语音服务", worker: "后台任务", operator: "运营人员" } as Record<string, string>)[value] ?? value; }
|
||
function conversationFailureLabel(value?: string) { return value === "context_preparation_failed" ? "背景资料准备失败,未进入语音通话" : undefined; }
|
||
function formatUsageMoney(micros: number, currency: string) { return `${currency} ${(micros / 1_000_000).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 6 })}`; }
|
||
function usageCategoryLabel(value: string) { return ({ RTC: "实时音视频", END_TO_END_VOICE: "端到端语音", ASR: "语音识别", LLM: "大模型", TTS: "语音合成", STORAGE: "存储", POST_PROCESSING: "后处理" } as Record<string, string>)[value] ?? value; }
|
||
function storyStatusLabel(value: string) { return ({ CANDIDATE: "等待审核", IN_REVIEW: "审核中", CONFIRMED: "已确认", REJECTED: "已拒绝", SUPERSEDED: "已替代" } as Record<string, string>)[value] ?? value; }
|
||
function memoryStatusLabel(value: string) { return ({ ACTIVE: "有效", DISABLED: "已停用", SUPERSEDED: "已替代", PENDING_DELETE: "删除中", DELETED: "已删除" } as Record<string, string>)[value] ?? value; }
|
||
function memoryIndexLabel(value: string) { return ({ PENDING: "等待中", ACTIVE: "已生效", STALE: "已失效", FAILED: "失败", DELETED: "已删除" } as Record<string, string>)[value] ?? value; }
|
||
function providerMemoryIndexLabel(value: string) { return ({ DISABLED: "未启用", PENDING: "同步中", ACTIVE: "已同步", STALE: "待清理", FAILED: "同步失败", DELETED: "已清理" } as Record<string, string>)[value] ?? value; }
|
||
function privacyStatusLabel(value: string) { return ({ SUBMITTED: "已提交", VERIFYING: "核验中", EXECUTING: "执行中", COMPLETED: "已完成", PARTIAL_FAILED: "部分失败" } as Record<string, string>)[value] ?? value; }
|
||
function privacyCategories() { return ["profile", "consents", "device_bindings", "conversations", "transcripts", "summaries", "stories", "memories"]; }
|
||
function privacyCategoryLabel(value: string) { return ({ profile: "长者档案", consents: "授权记录", device_bindings: "设备绑定", conversations: "通话", transcripts: "话轮", summaries: "摘要", stories: "故事", memories: "记忆" } as Record<string, string>)[value] ?? value; }
|
||
function privacyRequestTypeLabel(value: string) { return ({ ACCESS: "数据访问", CORRECTION: "资料更正", CONSENT_WITHDRAWAL: "授权撤回", DELETE: "主体删除" } as Record<string, string>)[value] ?? value; }
|
||
function consentTypeLabel(value: string) { return ({ transcript_storage: "保存文字记录", raw_audio_storage: "保存原始录音", story_extraction: "整理故事与摘要", personalized_memory: "个性化长期记忆", family_sharing: "家庭资源分享" } as Record<string, string>)[value] ?? value; }
|
||
function privacyRequestSummary(request: PrivacyRequest, counts: Record<string, number>) {
|
||
if (request.request_type === "DELETE") return `跨子系统删除对账:${request.subtasks.filter((item) => ["COMPLETED", "SKIPPED"].includes(item.status)).length}/${request.subtasks.length}。`;
|
||
if (request.request_type === "CORRECTION") {
|
||
const fields = Array.isArray(request.result_summary.corrected_fields) ? request.result_summary.corrected_fields.map(String) : [];
|
||
return `已完成资料更正并生成档案 v${String(request.result_summary.profile_version ?? "-")};更正字段:${fields.join("、") || "已核验"}。`;
|
||
}
|
||
if (request.request_type === "CONSENT_WITHDRAWAL") {
|
||
const types = Array.isArray(request.result_summary.withdrawn_consent_types) ? request.result_summary.withdrawn_consent_types.map((value) => consentTypeLabel(String(value))) : [];
|
||
return `已撤回:${types.join("、") || "所选授权已无当前有效记录"};停止活动通话 ${String(request.result_summary.stopped_conversation_count ?? 0)} 个。`;
|
||
}
|
||
return request.status === "COMPLETED" ? `已完成数据范围盘点,共${Object.values(counts).reduce((sum, count) => sum + Number(count), 0)}条/项记录。` : "请求已进入可重试的后台处理队列。";
|
||
}
|
||
function privacySubsystemLabel(value: string) { return ({ postgresql: "业务数据库", redis: "实时缓存", object_storage: "媒体对象", pgvector: "本地向量", provider_index: "外部索引", export_files: "私有导出" } as Record<string, string>)[value] ?? value; }
|
||
function privacySubtaskStatusLabel(value: string) { return ({ PENDING: "等待中", RUNNING: "执行中", COMPLETED: "已完成", FAILED: "失败", SKIPPED: "已核验" } as Record<string, string>)[value] ?? value; }
|
||
function exportStatusLabel(value: string) { return ({ QUEUED: "等待生成", RUNNING: "生成中", READY: "可下载", FAILED: "生成失败", EXPIRED: "已过期" } as Record<string, string>)[value] ?? value; }
|
||
function sharingResourceLabel(value: string) { return ({ STORY: "故事", TRANSCRIPT: "通话文字", RECORDING: "录音" } as Record<string,string>)[value] ?? value; }
|
||
function sharingPermissionLabel(value: string) { return ({ VIEW_TEXT: "查看文字", LISTEN: "收听", DOWNLOAD: "下载", EDIT: "编辑" } as Record<string,string>)[value] ?? value; }
|
||
function firmwareReleaseStatusLabel(value: string) { return ({ UPLOADING: "等待校验", READY: "已验证", PAUSED: "已暂停", RETIRED: "已停用" } as Record<string, string>)[value] ?? value; }
|
||
function firmwareRolloutStatusLabel(value: string) { return ({ DRAFT: "草稿", RUNNING: "发布中", PAUSED: "已暂停", COMPLETED: "已完成", ROLLED_BACK: "已回滚" } as Record<string, string>)[value] ?? value; }
|
||
function firmwareAssignmentStatusLabel(value: string) { return ({ ASSIGNED: "已分配", DOWNLOADING: "正在下载", INSTALLING: "正在安装", CONFIRMED: "设备已确认", FAILED: "失败", ROLLED_BACK: "已回滚" } as Record<string, string>)[value] ?? value; }
|
||
function firmwareAssignmentPhaseSummary(result: Record<string, unknown>) {
|
||
const phase = typeof result.phase === "string" ? result.phase : "";
|
||
const label = ({ downloading: "正在下载并校验制品", installing: "正在写入升级镜像", staging: "已写入非活动分区", reboot_pending: "已设置下次启动分区,等待重启", boot_verifying: "新分区启动健康确认中", confirmed: "新分区启动确认完成", rolled_back: "启动失败,设备已自动回退" } as Record<string, string>)[phase] ?? "等待设备阶段回执";
|
||
const activeSlot = typeof result.active_slot === "string" ? result.active_slot : "";
|
||
const targetSlot = typeof result.target_slot === "string" ? result.target_slot : "";
|
||
const rollbackVersion = typeof result.rollback_version === "string" ? result.rollback_version : "";
|
||
if (phase === "rolled_back" && rollbackVersion) return `${label} · 当前版本 ${rollbackVersion}${activeSlot ? ` · 分区 ${activeSlot}` : ""}`;
|
||
if (activeSlot && targetSlot) return `${label} · ${activeSlot} → ${targetSlot}`;
|
||
if (activeSlot) return `${label} · 分区 ${activeSlot}`;
|
||
return label;
|
||
}
|
||
function firmwareAssignmentEventSummary(event: FirmwareAssignmentEvent) {
|
||
const phase = event.phase ? firmwareAssignmentPhaseSummary({ phase: event.phase, active_slot: event.active_slot, target_slot: event.target_slot, rollback_version: event.rollback_version }) : firmwareAssignmentStatusLabel(event.assignment_status);
|
||
const attempt = event.boot_attempt_id ? ` · 启动尝试 ${event.boot_attempt_id.slice(0, 8)}` : "";
|
||
return `${phase} · ${event.command_status}${attempt}`;
|
||
}
|
||
function firmwareObservationReasonLabel(value: string) { return ({ INSTALLATION_IN_PROGRESS: "安装中,继续观察", DOWNLOAD_IN_PROGRESS: "下载中,继续观察", COMMAND_DELIVERED: "命令已送达,等待设备确认", COMMAND_ACKNOWLEDGED: "设备已确认命令,等待阶段回执", RESULT_RECONCILIATION_REQUIRED: "命令结果与阶段状态待对账" } as Record<string, string>)[value] ?? value; }
|
||
function formatBytes(value: number) { if (value < 1024) return `${value} B`; if (value < 1048576) return `${(value / 1024).toFixed(1)} KB`; return `${(value / 1048576).toFixed(1)} MB`; }
|
||
function conversationQuery(filters: { status: string; engineMode: string; from: string; to: string }, cursor?: string): ConversationQuery {
|
||
const query: ConversationQuery = { limit: 20 };
|
||
if (filters.status) query.status = filters.status as ConversationQuery["status"];
|
||
if (filters.engineMode) query.engine_mode = filters.engineMode as ConversationQuery["engine_mode"];
|
||
if (filters.from) query.from = localDateBoundary(filters.from).toISOString();
|
||
if (filters.to) query.to = localDateBoundary(filters.to, 1).toISOString();
|
||
if (cursor) query.cursor = cursor;
|
||
return query;
|
||
}
|
||
function localDateBoundary(value: string, dayOffset = 0) { const result = new Date(`${value}T00:00:00`); result.setDate(result.getDate() + dayOffset); return result; }
|
||
function isUUID(value: string) { return /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value); }
|
||
async function digestHex(value: string) {
|
||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
|
||
function waitForReconnect(signal: AbortSignal, milliseconds: number) {
|
||
return new Promise<void>((resolve) => {
|
||
if (signal.aborted) { resolve(); return; }
|
||
const timeout = window.setTimeout(done, milliseconds);
|
||
function done() { window.clearTimeout(timeout); signal.removeEventListener("abort", done); resolve(); }
|
||
signal.addEventListener("abort", done, { once: true });
|
||
});
|
||
}
|