Files
story/web/src/pages/SaaSWorkspace.tsx
T

2029 lines
223 KiB
TypeScript
Raw Normal View History

2026-08-26 06:41:07 +08:00
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import {
APIRequestError,
platformApi,
type Consent,
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 DeviceTwin,
type ExportTask,
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";
type Tab = "members" | "subjects" | "devices" | "firmware" | "conversations" | "stories" | "sharing" | "privacy";
type LoadState = "idle" | "loading" | "ready" | "error";
type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error";
type EventStreamState = "connecting" | "connected" | "reconnecting";
type ConversationPace = "slow" | "normal" | "lively";
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 };
export function SaaSWorkspace({ accessToken, accounts }: { accessToken: string; accounts: CurrentUser["accounts"] }) {
const [tab, setTab] = useState<Tab>("subjects");
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 [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 eventRefreshTimer = useRef<number | undefined>(undefined);
const lastEventRefreshAt = useRef(0);
const conversationDetailController = useRef<AbortController | undefined>(undefined);
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());
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 = 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)}”;我知道该授权可以单独撤回。`;
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) {
setTab("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) }
},
acknowledged: false,
confirmation: ""
});
}
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()) return;
openFamilyControl({ kind: "device_claim", household: selectedHousehold, subjectID: selectedSubjectID, subjectName: selectedSubject.preferred_name, claimCode: claimCode.trim(), acknowledged: false, confirmation: "" });
}
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 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) {
await runAction("故事已保存为新的人工修订版本。", async () => {
const response = await platformApi.reviseStory(story, content, reason, accessToken);
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) {
await runAction("故事候选及其候选事实已拒绝。", async () => {
const response = await platformApi.rejectStory(story, reason, accessToken);
setSelectedStory(response.data);
});
}
async function updateMemory(memory: CuratedMemory, input: { content?: string; status?: "ACTIVE" | "DISABLED"; 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 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(""); });
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" aria-label="家庭业务工作台">
<div className="workspace__header">
<div>
<p className="eyebrow">FAMILY OPERATIONS</p>
<h2>家庭业务工作台</h2>
<p>家庭、长者、授权、设备和通话数据均按服务端成员关系隔离。</p>
</div>
<div className="workspace__actions">
<span className={`event-stream-status event-stream-status--${eventStreamState}`} title={lastEventAt ? `最近事件:${formatDate(lastEventAt)}` : "等待状态事件"}>{eventStreamState === "connected" ? "状态实时更新" : eventStreamState === "reconnecting" ? "状态重连中" : "状态连接中"}</span>
<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>
<button type="button" className="button-secondary" onClick={() => void loadRoot()} disabled={state === "loading"}>刷新数据</button>
</div>
</div>
<form className="accept-invitation" onSubmit={(event) => void acceptInvitation(event)}>
<label><span>收到家庭邀请?</span><input value={acceptInvitationToken} minLength={32} maxLength={128} autoComplete="off" onChange={(event) => setAcceptInvitationToken(event.target.value)} placeholder="粘贴一次性邀请凭据" /><small>先只读预览目标客户组织、家庭、角色和到期时间,确认后才建立成员关系。</small></label>
<button type="submit" className="button-secondary" disabled={actionBusy || acceptInvitationToken.trim().length < 32 || acceptInvitationToken.trim().length > 128}>{actionBusy ? "正在核验…" : "预览并接受"}</button>
</form>
<details className="household-create" open={households.length === 0}>
<summary>{households.length === 0 ? "创建第一个家庭" : "新增家庭"}</summary>
<form className="tool-form" onSubmit={(event) => void createHousehold(event)}>
<div><strong>建立家庭空间</strong><span>创建成功后,当前账号自动成为所有者,可继续新增长者档案和邀请成员。</span></div>
<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 value={newHouseholdName} maxLength={200} onChange={(event) => setNewHouseholdName(event.target.value)} placeholder="例如:王家陪伴空间" required /></label>
<button type="submit" disabled={actionBusy || !newHouseholdTenantID || !newHouseholdName.trim()}>创建家庭</button>
</form>
</details>
{state === "loading" ? <StatePanel title="正在载入家庭数据" detail="正在校验客户组织和家庭成员关系…" /> : null}
{state === "error" ? <StatePanel title="家庭数据载入失败" detail={error ?? "未知错误"} action={() => void loadRoot()} /> : null}
{state === "ready" && households.length === 0 ? <StatePanel title="还没有可访问的家庭" detail="使用上方入口创建第一个家庭,或粘贴有效邀请凭据加入已有家庭。" /> : null}
{actionMessage ? <div className="workspace__action-message" role="status">{actionMessage}</div> : null}
{actionError ? <div className="workspace__inline-error" role="alert">{actionError}</div> : null}
{familyControlDraft ? <form id="family-controlled-operation" className={`sensitive-access-request controlled-operation family-controlled-operation ${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> : null}
{state === "ready" && selectedHousehold ? (
<>
<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={`${Object.values(presence).filter((item) => item.online).length}台在线`} />
</div>
{selectedSubject ? <ConversationReadinessPanel readiness={conversationReadiness} subject={selectedSubject}
loading={subjectLoading} onAction={(action) => setTab(action === "EDIT_PROFILE" || action === "MANAGE_CONSENTS" ? "subjects" : "devices")} /> : null}
<nav className="workspace__tabs" aria-label="业务模块">
<TabButton active={tab === "members"} onClick={() => setTab("members")}>家庭成员</TabButton>
<TabButton active={tab === "subjects"} onClick={() => setTab("subjects")}>长者与授权</TabButton>
<TabButton active={tab === "devices"} onClick={() => setTab("devices")}>设备管理</TabButton>
{canManageFirmware ? <TabButton active={tab === "firmware"} onClick={() => setTab("firmware")}>固件发布</TabButton> : null}
<TabButton active={tab === "conversations"} onClick={() => setTab("conversations")}>通话记录</TabButton>
<TabButton active={tab === "stories"} onClick={() => setTab("stories")}>故事与记忆</TabButton>
{canManageSharing ? <TabButton active={tab === "sharing"} onClick={() => setTab("sharing")}>资源分享</TabButton> : null}
{canReadPrivacy ? <TabButton active={tab === "privacy"} onClick={() => setTab("privacy")}>隐私请求</TabButton> : null}
</nav>
{subjectError ? <div className="workspace__inline-error" role="alert">{subjectError}</div> : null}
{tab === "members" ? <MemberPanel household={selectedHousehold} members={members} invitations={invitations}
canManage={canManageMembers} canTransfer={canTransferOwnership} actionBusy={actionBusy}
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" ? (
<>
{canWriteSubject ? <div className="workspace__tools">
<form className="tool-form subject-create-form" onSubmit={(event) => void createSubject(event)}>
<div><strong>新增长者档案</strong><span>背景和交流目标会随档案版本冻结到下一次新通话;内部未授权字段不会发送给语音供应商。</span></div>
<label><span>称呼</span><input value={newSubjectName} maxLength={120} onChange={(event) => setNewSubjectName(event.target.value)} placeholder="例如:王奶奶" required /></label>
<label><span>交流节奏</span><select value={newSubjectPace} onChange={(event) => setNewSubjectPace(event.target.value as ConversationPace)}><option value="slow">放慢语速</option><option value="normal">自然语速</option><option value="lively">轻快交流</option></select></label>
<label className="tool-form__wide"><span>背景资料</span><textarea value={newSubjectBackground} maxLength={500} onChange={(event) => setNewSubjectBackground(event.target.value)} placeholder="例如:年轻时从事铁路工作,长期生活在杭州" required /></label>
<label className="tool-form__wide"><span>本次交流目标</span><input value={newSubjectGoal} maxLength={200} onChange={(event) => setNewSubjectGoal(event.target.value)} placeholder="例如:聊聊第一次参加工作的经历" required /></label>
<label><span>兴趣(逗号分隔)</span><input value={newSubjectInterests} onChange={(event) => setNewSubjectInterests(event.target.value)} placeholder="老照片,家乡变化" /><small>最多保存8项,每项最多50字符;确认区展示实际保存内容。</small></label>
<label><span>优先话题(逗号分隔)</span><input value={newSubjectTopics} onChange={(event) => setNewSubjectTopics(event.target.value)} placeholder="工作经历,家庭往事" /><small>最多保存8项,每项最多50字符;确认区展示实际保存内容。</small></label>
<button type="submit" disabled={actionBusy || !newSubjectName.trim() || !newSubjectBackground.trim() || !newSubjectGoal.trim()}>创建档案</button>
</form>
<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> : <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} onUpdateSubject={updateSubjectProfile} onConfirmFact={confirmFact} onWithdrawConsent={withdrawConsent} />
</>
) : null}
{tab === "devices" ? (
<>
{canManageDevice ? <form className="workspace__claim tool-form" onSubmit={(event) => void claimDevice(event)}>
<div><strong>绑定新设备</strong><span>一次性绑定码使用后立即失效;设备将绑定到当前长者。</span></div>
<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 value={claimCode} minLength={16} maxLength={128} autoComplete="off" onChange={(event) => setClaimCode(event.target.value)} placeholder="输入设备屏幕上的绑定码" required /><small>绑定码由未绑定设备显示,云端工作台不会生成;长度为16至128字符。</small></label>
<button type="submit" disabled={actionBusy || !selectedSubjectID || claimCode.trim().length < 16 || claimCode.trim().length > 128}>绑定设备</button>
</form> : <p className="workspace__readonly-note">当前家庭角色不能绑定或修改设备配置。</p>}
<DevicePanel devices={devices.filter((item) => item.household_id === selectedHousehold.id)} presence={presence} twins={twins} canManage={canManageDevice} actionBusy={actionBusy} onSetVolume={updateDeviceVolume} onDiagnostics={requestDeviceDiagnostics} />
</>
) : 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 subjects={subjects} selectedID={selectedSubjectID} onSelect={setSelectedSubjectID}
stories={stories} memories={memories} selected={selectedStory} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory}
onOpen={openStory} onOpenConversation={openStoryConversation} onRevise={reviseStory} onConfirm={confirmStory} onReject={rejectStory} 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} loading={subjectLoading} canManage={canManagePrivacy} actionBusy={actionBusy}
onCreateAccess={createPrivacyAccessRequest} onCreateDelete={createPrivacyDeleteRequest} onCreateExport={createExportTask} onDownloadExport={downloadExport} /> : null}
</>
) : null}
</section>
);
}
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, members, invitations, canManage, canTransfer, actionBusy, inviteeHint, onInviteeHint, inviteRole, onInviteRole, issuedToken, onDismissToken, onCreateInvitation, onUpdateRole, onRemove, onRevoke, onTransfer }: {
household: 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>;
}) {
return <div className="member-layout">
{canManage ? <form className="tool-form" onSubmit={(event) => void onCreateInvitation(event)}>
<div><strong>创建成员邀请</strong><span>邀请凭据只显示一次,默认七天有效。</span></div>
<label><span>接收人提示</span><input 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>
<button type="submit" disabled={actionBusy}>生成邀请</button>
</form> : <p className="workspace__readonly-note">当前角色可以查看家庭成员,但不能邀请、修改或移除成员。</p>}
{issuedToken ? <div className="invitation-secret" role="status"><div><strong>请立即复制邀请凭据</strong><p>刷新或关闭后不再显示;服务端只保存摘要。</p></div><input readOnly value={issuedToken} aria-label="一次性邀请凭据" /><button type="button" className="button-secondary button-small" onClick={() => void navigator.clipboard.writeText(issuedToken)}>复制</button><button type="button" className="button-small" onClick={onDismissToken}>我已保存</button></div> : null}
<div className="member-columns">
<section><div className="section-title"><h4>{household.name}的成员</h4><span>{members.length}</span></div>
{members.map((member) => <article className="member-row" key={member.id}><div><strong>{member.display_name}</strong><p>{member.is_owner ? "家庭所有者" : roleLabel(member.role)} · 加入于{formatDate(member.joined_at)}</p></div><div className="member-row__actions">
<span className={member.is_owner ? "tag tag--confirmed" : "tag"}>{roleLabel(member.role)}</span>
{canManage && !member.is_owner ? <button type="button" className="button-small button-secondary" disabled={actionBusy} onClick={() => void onUpdateRole(member)}>调整角色</button> : null}
{canTransfer && !member.is_owner ? <button type="button" className="button-small" disabled={actionBusy} onClick={() => void onTransfer(member)}>转移所有权</button> : null}
{canManage && !member.is_owner ? <button type="button" className="button-small button-danger" disabled={actionBusy} onClick={() => void onRemove(member)}>移除</button> : null}
</div></article>)}
</section>
<section><div className="section-title"><h4>邀请记录</h4><span>{invitations.filter((item) => item.status === "pending").length}待接受</span></div>
{!canManage ? <p className="empty-copy">只有所有者和管理员可以查看邀请历史。</p> : invitations.length ? invitations.map((invitation) => <article className="member-row" key={invitation.id}><div><strong>{invitation.invitee_hint || "未填写接收人提示"}</strong><p>{roleLabel(invitation.role)} · 截止{formatDate(invitation.expires_at)}</p></div><div className="member-row__actions"><span className={`tag tag--${invitation.status}`}>{invitationStatusLabel(invitation.status)}</span>{invitation.status === "pending" ? <button type="button" className="button-small button-danger" disabled={actionBusy} onClick={() => void onRevoke(invitation)}>撤销</button> : null}</div></article>) : <p className="empty-copy">暂无邀请记录。</p>}
</section>
</div>
</div>;
}
function SubjectPanel({ subjects, selectedID, onSelect, subject, facts, consents, loading, canWrite, actionBusy, onUpdateSubject, onConfirmFact, onWithdrawConsent }: {
subjects: Subject[]; selectedID?: string; onSelect: (id: string) => void; subject?: Subject;
facts: SubjectFact[]; consents: Consent[]; loading: boolean; canWrite: boolean; actionBusy: boolean;
onUpdateSubject: (subject: Subject, input: UpdateSubjectRequest) => void;
onConfirmFact: (fact: SubjectFact) => Promise<void>; onWithdrawConsent: (consent: Consent) => Promise<void>;
}) {
return <div className="workspace__split">
<aside className="resource-list">
<div className="resource-list__title"><strong>长者档案</strong><span>{subjects.length}</span></div>
{subjects.map((item) => <button type="button" className={item.id === selectedID ? "resource-button active" : "resource-button"} key={item.id} onClick={() => onSelect(item.id)}>
<strong>{item.preferred_name}</strong><span>{item.language} · {statusLabel(item.status)}</span>
</button>)}
{subjects.length === 0 ? <p className="empty-copy">这个家庭还没有长者档案。</p> : null}
</aside>
<div className="resource-detail">
{loading ? <StatePanel title="正在读取长者资料" detail="同步档案、事实、授权和通话摘要…" /> : subject ? <>
<div className="resource-detail__head"><div><p className="eyebrow">SUBJECT PROFILE</p><h3>{subject.preferred_name}</h3><p>{subject.timezone} · 档案版本 v{subject.version}</p></div><span className={canWrite ? "permission permission--write" : "permission"}>{canWrite ? "可编辑" : "只读"}</span></div>
<div className="detail-grid"><Info label="语言" value={subject.language} /><Info label="状态" value={statusLabel(subject.status)} /><Info label="交流偏好" value={objectSummary(subject.conversation_preferences)} /><Info label="扩展资料" value={objectSummary(subject.profile_data)} /></div>
<SubjectProfileEditor subject={subject} canWrite={canWrite} actionBusy={actionBusy} onSave={onUpdateSubject} />
<div className="section-columns">
<section><div className="section-title"><h4>稳定事实</h4><span>{facts.length}</span></div>{facts.length ? facts.map((fact) => <article className="data-row" key={fact.id}><div><strong>{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><div className="section-title"><h4>分项授权</h4><span>{consents.filter((item) => item.effective).length}/5有效</span></div>{consents.length ? consents.map((consent) => <article className="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>
</> : <StatePanel title="请选择一位长者" detail="选择后查看档案、稳定事实和分项授权。" />}
</div>
</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").join(","));
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").join(","));
const normalizedInterests = splitContextList(interests);
const normalizedTopics = splitContextList(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(normalizedInterests) !== JSON.stringify(profileList(subject.profile_data, "interests")) || JSON.stringify(normalizedTopics) !== JSON.stringify(profileList(subject.conversation_preferences, "preferred_topics"));
useEffect(() => {
setName(subject.preferred_name);
setBackground(profileString(subject.profile_data, "background"));
setInterests(profileList(subject.profile_data, "interests").join(","));
setGoal(profileString(subject.conversation_preferences, "conversation_goal"));
setPace(profilePace(subject.conversation_preferences));
setTopics(profileList(subject.conversation_preferences, "preferred_topics").join(","));
}, [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: normalizedInterests },
conversation_preferences: { ...subject.conversation_preferences, conversation_goal: goal.trim(), pace, preferred_topics: normalizedTopics }
});
}
return <form className="profile-editor" onSubmit={(event) => void submit(event)}>
<div className="section-title"><div><h4>通话背景与交流目标</h4><p>只将下列白名单资料带入新通话;已开始的通话继续使用原冻结版本。</p></div><span>{background && goal ? "资料完整" : "等待完善"}</span></div>
<label><span>称呼</span><input disabled={!canWrite} value={name} maxLength={120} onChange={(event) => setName(event.target.value)} required /></label>
<label><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="profile-editor__wide"><span>背景资料</span><textarea disabled={!canWrite} value={background} maxLength={500} onChange={(event) => setBackground(event.target.value)} required /></label>
<label className="profile-editor__wide"><span>交流目标</span><input disabled={!canWrite} value={goal} maxLength={200} onChange={(event) => setGoal(event.target.value)} required /></label>
<label><span>兴趣(逗号分隔)</span><input disabled={!canWrite} value={interests} onChange={(event) => setInterests(event.target.value)} /></label>
<label><span>优先话题(逗号分隔)</span><input disabled={!canWrite} value={topics} onChange={(event) => setTopics(event.target.value)} /></label>
{canWrite ? <button type="submit" disabled={actionBusy || !profileChanged || !name.trim() || !background.trim() || !goal.trim()}>核对并保存新版本</button> : null}
</form>;
}
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, presence, twins, canManage, actionBusy, onSetVolume, onDiagnostics }: { devices: ManagedDevice[]; presence: Record<string, DevicePresence>; twins: Record<string, DeviceTwin>; canManage: boolean; actionBusy: boolean; onSetVolume: (device: ManagedDevice, speakerVolume: number) => void; onDiagnostics: (device: ManagedDevice) => Promise<void> }) {
if (devices.length === 0) return <StatePanel title="这个家庭还没有已绑定设备" detail="使用一次性绑定码完成设备与长者绑定后,设备会显示在这里。" />;
return <div className="device-grid">{devices.map((device) => <DeviceCard key={device.id} device={device} online={presence[device.id]} twin={twins[device.id]} canManage={canManage} actionBusy={actionBusy} onSetVolume={onSetVolume} onDiagnostics={onDiagnostics} />)}</div>;
}
function DeviceCard({ device, online, twin, canManage, actionBusy, onSetVolume, onDiagnostics }: { device: ManagedDevice; online?: DevicePresence; twin?: DeviceTwin; canManage: boolean; actionBusy: boolean; onSetVolume: (device: ManagedDevice, speakerVolume: number) => void; onDiagnostics: (device: ManagedDevice) => Promise<void> }) {
const desiredSpeakerVolume = deviceSpeakerVolume(twin?.desired_config);
const reportedSpeakerVolume = deviceSpeakerVolume(twin?.reported_config);
const [speakerVolume, setSpeakerVolume] = useState(desiredSpeakerVolume ?? 60);
const uploadQueue = twin ? recordingQueueFromTwin(twin) : undefined;
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);
}
return <article className="device-card">
<div className="device-card__head"><div className={online?.online ? "presence-dot online" : "presence-dot"} /><div><strong>{device.model}</strong><span>{device.subject_name}</span></div><span className={canManage ? "permission permission--write" : "permission"}>{canManage ? "可配置" : "只读"}</span></div>
<div className="detail-grid"><Info label="在线状态" value={online?.online ? "在线" : "离线"} /><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}
<p className="device-card__time">最后活动:{device.last_reported_at ? formatDate(device.last_reported_at) : "从未上报"}</p>
</article>;
}
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;
}) {
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={() => void onOpen(item.id)}><div><strong>{formatDate(item.created_at)}</strong><span>{conversationFailureLabel(item.failure_code) ?? `${engineModeLabel(item.engine_mode)} · ${retentionModeLabel(item.retention_mode)}`}</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>
<div className="conversation-detail">{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>
</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)}>播放</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">当前没有已通过完整性和WAV容器校验的录音分片。</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({ subjects, selectedID, onSelect, stories, memories, selected, loading, actionBusy, canReview, onOpen, onOpenConversation, onRevise, onConfirm, onReject, onUpdateMemory, onDeleteMemory }: {
subjects: Subject[]; selectedID?: string; onSelect: (id: string) => void; 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) => Promise<void>;
onConfirm: (story: Story, reason: string) => Promise<void>; onReject: (story: Story, reason: string) => Promise<void>;
onUpdateMemory: (memory: CuratedMemory, input: { content?: string; status?: "ACTIVE" | "DISABLED"; reason: string }) => Promise<boolean>;
onDeleteMemory: (memory: CuratedMemory, reason: string) => Promise<boolean>;
}) {
const [content, setContent] = useState("");
const [reason, setReason] = useState("");
const [memoryQuery, setMemoryQuery] = useState("");
const [selectedMemoryID, setSelectedMemoryID] = useState<string>();
useEffect(() => { setContent(selected?.content ?? ""); setReason(""); }, [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]);
return <div className="story-layout">
<aside className="story-sidebar">
<label><span>长者</span><select value={selectedID ?? ""} onChange={(event) => { setSelectedMemoryID(undefined); onSelect(event.target.value); }}>{subjects.map((subject) => <option value={subject.id} key={subject.id}>{subject.preferred_name}</option>)}</select></label>
<div className="section-title"><h4>故事候选</h4><span>{stories.length}</span></div>
{stories.map((story) => <button type="button" className={!selectedMemory && selected?.id === story.id ? "story-button active" : "story-button"} key={story.id} onClick={() => { setSelectedMemoryID(undefined); void onOpen(story.id); }}><strong>{story.title}</strong><span>{storyStatusLabel(story.status)} · v{story.revision_no}</span></button>)}
{!stories.length ? <p className="empty-copy">尚未从通话中整理出故事候选。</p> : null}
<div className="section-title memory-title"><h4>已确认记忆</h4><span>{visibleMemories.length}/{memories.length}</span></div>
<input className="memory-search" value={memoryQuery} maxLength={200} onChange={(event) => setMemoryQuery(event.target.value)} placeholder="搜索当前长者的记忆" />
{visibleMemories.map((memory) => <article className={selectedMemory?.id === memory.id ? "memory-row selected" : "memory-row"} key={memory.id}><button type="button" className="memory-row__select" disabled={actionBusy} onClick={() => setSelectedMemoryID(memory.id)}><strong>{memory.content}</strong><span>{memoryStatusLabel(memory.status)} · 索引{memoryIndexLabel(memory.index_status)} · v{memory.version_no}</span></button><button type="button" className="button-small button-secondary" disabled={actionBusy} onClick={() => setSelectedMemoryID(memory.id)}>{canReview ? "管理" : "查看"}</button></article>)}
</aside>
<div className="story-detail">{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 ? <label className="story-reason"><span>审核或修订原因</span><input value={reason} disabled={actionBusy} onChange={(event) => setReason(event.target.value)} placeholder="必填,用于审计和版本追踪" /></label> : null}
{reviewable ? <div className="story-actions"><button type="button" disabled={actionBusy || !reason.trim() || !content.trim() || content === selected.content} onClick={() => void onRevise(selected, content.trim(), reason.trim())}>保存新版本</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()} onClick={() => void onReject(selected, reason.trim())}>拒绝候选</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) => <article className="source-row" key={fact.id}><strong>{fact.fact_type} · {fact.statement_basis === "subject_statement" ? "长者陈述" : "模型推断"}</strong><p>{fact.content}</p><span className={`tag tag--${fact.status.toLowerCase()}`}>{factStatusLabel(fact.status.toLowerCase())}</span></article>)}</section></div>
</> : <StatePanel title="请选择一个故事候选" detail="人工查看原话来源后,可以修订、确认或拒绝;系统不会自动写入长期记忆。" />}</div>
</div>;
}
function MemoryManagementPanel({ memory, actionBusy, canReview, onUpdate, onDelete, onBack }: {
memory: CuratedMemory; actionBusy: boolean; canReview: boolean;
onUpdate: (memory: CuratedMemory, input: { content?: string; status?: "ACTIVE" | "DISABLED"; 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 [deleteConfirmed, setDeleteConfirmed] = useState(false);
const mutable = memory.status === "ACTIVE" || memory.status === "DISABLED";
useEffect(() => {
setContent(memory.content);
setReason("");
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()) return;
if (await onUpdate(memory, { content: content.trim(), reason: reason.trim() })) setReason("");
}
async function submitStatus(event: FormEvent) {
event.preventDefault();
if (!mutable || !reason.trim()) return;
const nextStatus = memory.status === "ACTIVE" ? "DISABLED" : "ACTIVE";
if (await onUpdate(memory, { status: nextStatus, reason: reason.trim() })) setReason("");
}
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(""); 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={String(memory.source_refs.length)} /><Info label="最近变更" value={formatDate(memory.updated_at)} /></div>
<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><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()}>{actionBusy ? "正在保存…" : "保存为新版本"}</button></form> : null}
{mode === "STATUS" ? <form className="memory-management__form" onSubmit={(event) => void submitStatus(event)}><p>{memory.status === "ACTIVE" ? "停用后立即停止在新通话中召回,当前索引会失效。" : "重新启用前服务端会再次检查个性化记忆授权,并重新建立索引。"}</p><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()}>{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 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">
<form className="tool-form" onSubmit={(event) => void submit(event)}>
<div><strong>创建具体资源分享</strong><span>文字、录音和编辑权限彼此独立;匿名口令只显示一次。</span></div>
<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="tool-form__wide"><span>分享用途</span><input value={purpose} maxLength={500} onChange={(event) => setPurpose(event.target.value)} placeholder="说明为什么分享该具体资源" required /></label>
<label className="consent-confirm"><input type="checkbox" checked={loginRequired} disabled={permission === "EDIT"} onChange={(event) => setLoginRequired(event.target.checked)} /><span>仅允许指定接收人登录后访问;取消勾选将生成匿名口令。</span></label>
<button type="submit" disabled={actionBusy || !resourceID || !granteeID || !purpose.trim()}>创建分享</button>
</form>
{issued ? <div className="invitation-token" role="status"><div><strong>{issued.token ? "匿名访问口令只显示一次" : "登录分享已创建"}</strong><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><button type="button" className="button-secondary" onClick={onDismissIssued}>我已保存</button></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, loading, canManage, actionBusy, onCreateAccess, onCreateDelete, onCreateExport, onDownloadExport }: {
subjects: Subject[]; selectedID?: string; onSelect: (id: string) => void; requests: PrivacyRequest[]; exports: ExportTask[];
loading: boolean; canManage: boolean; actionBusy: boolean; onCreateAccess: () => Promise<void>; 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("");
useEffect(() => {
setDeleteOpen(false);
setDeleteReason("");
setDeleteConfirmation("");
}, [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("");
}
}
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 || !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 ? <form className="privacy-delete-confirmation" 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> : 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>{request.request_type === "ACCESS" ? "数据访问" : request.request_type === "DELETE" ? "主体删除" : 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>{request.request_type === "DELETE" ? `跨子系统删除对账:${request.subtasks.filter((item) => ["COMPLETED", "SKIPPED"].includes(item.status)).length}/${request.subtasks.length}。` : request.status === "COMPLETED" ? `已完成数据范围盘点,共${Object.values(counts).reduce((sum, count) => sum + Number(count), 0)}条/项记录。` : "请求已进入可重试的后台处理队列。"}</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 }: { title: string; detail: string; action?: () => void }) { return <div className="state-panel"><strong>{title}</strong><p>{detail}</p>{action ? <button type="button" className="button-secondary" onClick={action}>重新加载</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
}
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)}。服务端将在同一事务创建档案和审计记录;授权、设备绑定和通话需分别完成。</>;
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 objectSummary(value: Record<string, unknown>) { const count = Object.keys(value).length; return count ? `${count}项已配置` : "未配置"; }
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)}”`);
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 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; }
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 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 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; }
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 });
});
}