chore: checkpoint 第一版
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-06 17:04:11 +08:00
parent a8cdec98d1
commit 0c2151ace9
659 changed files with 185855 additions and 554 deletions
+34
View File
@@ -25,6 +25,7 @@ export type DeviceBindingImpact = components["schemas"]["DeviceBindingImpact"];
export type DeviceBindingChangeResult = components["schemas"]["DeviceBindingChangeResult"];
export type FirmwareSigningKey = components["schemas"]["FirmwareSigningKey"];
export type FirmwareRelease = components["schemas"]["FirmwareRelease"];
export type FirmwareDownload = components["schemas"]["FirmwareDownload"];
export type FirmwareReleaseImpact = components["schemas"]["FirmwareReleaseImpact"];
export type FirmwareRollout = components["schemas"]["FirmwareRollout"];
export type FirmwareAssignment = components["schemas"]["FirmwareAssignment"];
@@ -147,6 +148,39 @@ export const platformApi = {
authenticatedJSON<Envelope<DeviceCredentialIssue>>("POST", `/api/v1/ops/devices/${deviceID}/credentials/rotate`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
issueOpsDeviceClaim: (tenantID: string, deviceID: string, accessToken: string) =>
authenticatedJSON<Envelope<DeviceClaimIssue>>("POST", `/api/v1/ops/devices/${deviceID}/claims`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
transitionOpsDeviceActivation: (tenantID: string, device: OpsDevice, targetState: components["schemas"]["DeviceActivationState"], reason: string, accessToken: string) =>
authenticatedJSON<Envelope<OpsDevice>>("POST", `/api/v1/ops/devices/${device.id}/activation-transitions`, accessToken, undefined,
{ target_state: targetState, reason, expected_revision: device.activation_revision }, { "X-Tenant-Id": tenantID }),
opsFirmwareSigningKeys: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
authenticatedJSON<Envelope<{ items: FirmwareSigningKey[] }>>("GET", "/api/v1/ops/firmware-signing-keys", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
registerOpsFirmwareSigningKey: (tenantID: string, input: components["schemas"]["RegisterFirmwareSigningKeyRequest"], accessToken: string) =>
authenticatedJSON<Envelope<{ key_id: string; status: string }>>("POST", "/api/v1/ops/firmware-signing-keys", accessToken, undefined, input, { "X-Tenant-Id": tenantID }),
revokeOpsFirmwareSigningKey: (tenantID: string, keyID: string, accessToken: string) =>
authenticatedJSON<Envelope<FirmwareSigningKey>>("POST", "/api/v1/ops/firmware-signing-keys/revoke", accessToken, undefined, { key_id: keyID }, { "X-Tenant-Id": tenantID }),
opsFirmwareReleases: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
authenticatedJSON<Envelope<{ items: FirmwareRelease[] }>>("GET", "/api/v1/ops/firmware-releases", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
prepareOpsFirmwareRelease: (tenantID: string, input: components["schemas"]["PrepareOpsFirmwareReleaseRequest"], accessToken: string) =>
authenticatedJSON<Envelope<FirmwareRelease>>("POST", "/api/v1/ops/firmware-releases", accessToken, undefined, input, { "X-Tenant-Id": tenantID }),
renewOpsFirmwareUpload: (tenantID: string, releaseID: string, accessToken: string) =>
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/ops/firmware-releases/${releaseID}/upload-session`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
finalizeOpsFirmwareRelease: (tenantID: string, releaseID: string, accessToken: string) =>
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/ops/firmware-releases/${releaseID}/finalize`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
pauseOpsFirmwareRelease: (tenantID: string, releaseID: string, accessToken: string) =>
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/ops/firmware-releases/${releaseID}/pause`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
resumeOpsFirmwareRelease: (tenantID: string, releaseID: string, accessToken: string) =>
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/ops/firmware-releases/${releaseID}/resume`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
retireOpsFirmwareRelease: (tenantID: string, releaseID: string, accessToken: string) =>
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/ops/firmware-releases/${releaseID}/retire`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
downloadOpsFirmwareRelease: (tenantID: string, releaseID: string, accessToken: string) =>
authenticatedJSON<Envelope<FirmwareDownload>>("GET", `/api/v1/ops/firmware-releases/${releaseID}/download`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
opsFirmwareRollouts: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
authenticatedJSON<Envelope<{ items: FirmwareRollout[] }>>("GET", "/api/v1/ops/firmware-rollouts", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
startOpsFirmwareRollout: (tenantID: string, input: components["schemas"]["StartFirmwareRolloutRequest"], accessToken: string) =>
authenticatedJSON<Envelope<FirmwareRollout>>("POST", "/api/v1/ops/firmware-rollouts", accessToken, undefined, input, { "X-Tenant-Id": tenantID }),
opsFirmwareRollout: (tenantID: string, rolloutID: string, accessToken: string, signal?: AbortSignal) =>
authenticatedJSON<Envelope<FirmwareRollout>>("GET", `/api/v1/ops/firmware-rollouts/${rolloutID}`, accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
pauseOpsFirmwareRollout: (tenantID: string, rolloutID: string, accessToken: string) =>
authenticatedJSON<Envelope<FirmwareRollout>>("POST", `/api/v1/ops/firmware-rollouts/${rolloutID}/pause`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
opsContentQuality: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
authenticatedJSON<Envelope<{ items: ContentQualitySummary[] }>>("GET", "/api/v1/ops/content-quality", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
opsConfigVersions: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
+1400 -1
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -23,6 +23,7 @@ import {
BarChartOutlined,
BellOutlined,
CloudServerOutlined,
CloudUploadOutlined,
DashboardOutlined,
DollarOutlined,
DatabaseOutlined,
@@ -43,9 +44,10 @@ import { OpsBillingWorkspace } from "./OpsBillingWorkspace";
import { OpsConfigWorkspace } from "./OpsConfigWorkspace";
import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace";
import { OpsDeviceWorkspace } from "./OpsDeviceWorkspace";
import { OpsFirmwareWorkspace } from "./OpsFirmwareWorkspace";
import { OpsContentModelWorkspace } from "./OpsContentModelWorkspace";
type MaintenanceArea = "overview" | "devices" | "config" | "models" | "quality" | "billing" | "alerts" | "diagnostics";
type MaintenanceArea = "overview" | "devices" | "firmware" | "config" | "models" | "quality" | "billing" | "alerts" | "diagnostics";
type HealthState = {
loading: boolean;
@@ -60,6 +62,7 @@ type HealthState = {
const areaMeta: Record<MaintenanceArea, { title: string; description: string; icon: ReactNode }> = {
overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> },
devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> },
firmware: { title: "固件管理", description: "维护租户级固件版本、可信签名、公私有上传下载与分发生命周期。", icon: <CloudUploadOutlined /> },
config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: <SettingOutlined /> },
models: { title: "大模型管理", description: "按客户组织配置、测试并激活聊天总结、关键词和故事候选生成模型。", icon: <RobotOutlined /> },
quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: <BarChartOutlined /> },
@@ -130,6 +133,7 @@ export function MaintenancePortal() {
const matches: Array<[string[], MaintenanceArea]> = [
[["总览", "健康", "版本", "health"], "overview"],
[["设备管理", "资产", "密钥", "device management"], "devices"],
[["固件", "升级", "ota", "firmware"], "firmware"],
[["配置", "灰度", "模型路由", "config"], "config"],
[["大模型", "模型", "豆包", "ollama", "model"], "models"],
[["质量", "审核", "反馈", "quality"], "quality"],
@@ -144,6 +148,7 @@ export function MaintenancePortal() {
{ type: "group", label: "平台维护", children: [
{ key: "overview", icon: <DashboardOutlined />, label: "维护总览" },
{ key: "devices", icon: <DatabaseOutlined />, label: "设备管理" },
{ key: "firmware", icon: <CloudUploadOutlined />, label: "固件管理" },
{ key: "config", icon: <SettingOutlined />, label: "运行配置" },
{ key: "models", icon: <RobotOutlined />, label: "大模型管理" },
{ key: "quality", icon: <BarChartOutlined />, label: "内容质量" },
@@ -196,6 +201,7 @@ export function MaintenancePortal() {
{auth.currentUser && auth.oidcUser && hasOperationsAccess ? <section className="maintenance-workspace">
{area === "overview" ? <MaintenanceOverview health={health} accounts={operationsAccounts} /> : null}
{area === "devices" ? <OpsDeviceWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "firmware" ? <OpsFirmwareWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "config" ? <OpsConfigWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "models" ? <OpsContentModelWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "quality" ? <ContentQualityWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
+47 -6
View File
@@ -12,6 +12,11 @@ type DeviceForm = {
minimum_firmware_version?: string;
assignment?: string;
status?: "registered" | "quarantined";
voice_runtime: "legacy_voicechat" | "volc_hardware_agent";
hardware_agent_bot_id?: string;
hardware_agent_transport?: "high_quality" | "low_load";
hardware_agent_minimum_sdk_version?: string;
activation_state?: "AUTO" | "FACTORY";
};
export function OpsDeviceWorkspace({ accessToken, accounts }: { accessToken: string; accounts: Account[] }) {
@@ -57,7 +62,7 @@ export function OpsDeviceWorkspace({ accessToken, accounts }: { accessToken: str
function beginCreate() {
setEditing(undefined);
form.resetFields();
form.setFieldsValue({ model: "voice-terminal", status: "registered" });
form.setFieldsValue({ model: "voice-terminal", status: "registered", voice_runtime: "legacy_voicechat", activation_state: "AUTO" });
setOpen(true);
}
@@ -69,7 +74,8 @@ export function OpsDeviceWorkspace({ accessToken, accounts }: { accessToken: str
minimum_security_version: device.minimum_security_version,
minimum_firmware_version: device.minimum_firmware_version,
assignment: device.household_id && device.subject_id ? `${device.household_id}|${device.subject_id}` : undefined,
status: device.status === "quarantined" ? "quarantined" : "registered"
status: device.status === "quarantined" ? "quarantined" : "registered",
voice_runtime: device.voice_runtime
});
setOpen(true);
}
@@ -87,7 +93,8 @@ export function OpsDeviceWorkspace({ accessToken, accounts }: { accessToken: str
minimum_firmware_version: values.minimum_firmware_version ?? "",
status: values.status,
household_id: householdID ?? "",
subject_id: subjectID ?? ""
subject_id: subjectID ?? "",
voice_runtime: values.voice_runtime
}, accessToken);
message.success("设备信息已更新");
} else {
@@ -97,11 +104,15 @@ export function OpsDeviceWorkspace({ accessToken, accounts }: { accessToken: str
minimum_security_version: values.minimum_security_version,
minimum_firmware_version: values.minimum_firmware_version,
household_id: householdID,
subject_id: subjectID
subject_id: subjectID,
voice_runtime: values.voice_runtime,
activation_state: values.activation_state === "FACTORY" ? "FACTORY" : undefined
}, accessToken);
downloadCredential(response.data.credential);
if (response.data.claim) {
showClaim(response.data.claim, "设备已创建,密钥文件已下载");
} else if (response.data.device.activation_state === "FACTORY") {
Modal.success({ title: "出厂设备已登记,密钥文件已下载", content: `设备 ID:${response.data.device.id}。设备仍处于 FACTORY,生产质检完成后请执行“出厂放行”,届时平台才允许生成绑定码。` });
} else {
Modal.success({ title: "设备已创建,密钥文件已下载", content: `设备 ID:${response.data.device.id}。该设备已直接分配给长者,无需绑定码。密钥明文不会再次显示,请立即交付到对应设备安全区。` });
}
@@ -154,6 +165,21 @@ export function OpsDeviceWorkspace({ accessToken, accounts }: { accessToken: str
}
}
function transitionActivation(device: OpsDevice, targetState: OpsDevice["activation_state"], title: string, reason: string) {
Modal.confirm({
title,
content: `设备将从 ${device.activation_state} 迁移到 ${targetState},本次操作会写入不可变更的激活审计记录。`,
okText: "确认迁移",
cancelText: "取消",
okButtonProps: { danger: targetState === "LOST" },
onOk: async () => {
await platformApi.transitionOpsDeviceActivation(tenantID, device, targetState, reason, accessToken);
message.success(`激活状态已更新为 ${targetState}`);
setRevision((value) => value + 1);
}
});
}
return <div className="ops-device-workspace">
<Card className="ops-device-toolbar">
<div><strong>设备资产与密钥</strong><span>设备 ID 由平台生成;密钥仅在创建或轮换时下载一次,数据库只保存加密密文。</span></div>
@@ -169,12 +195,20 @@ export function OpsDeviceWorkspace({ accessToken, accounts }: { accessToken: str
{ title: "设备 ID", dataIndex: "id", width: 300, render: (value: string) => <Typography.Text copyable={{ text: value }} code>{value}</Typography.Text> },
{ title: "型号", dataIndex: "model", width: 150, render: (_: unknown, item: OpsDevice) => <><strong>{item.model}</strong><br/><small>{item.hardware_revision || "未填硬件版本"}</small></> },
{ title: "家庭 / 长者", width: 190, render: (_: unknown, item: OpsDevice) => item.household_name ? <><strong>{item.household_name}</strong><br/><span>{item.subject_name}</span></> : <Tag>尚未分配</Tag> },
{ title: "激活状态", width: 150, render: (_: unknown, item: OpsDevice) => <><Tag color={activationColor(item.activation_state)}>{item.activation_state}</Tag><br/><small>修订 {item.activation_revision}</small></> },
{ title: "设备状态", dataIndex: "status", width: 110, render: (value: string) => <Tag color={value === "registered" ? "green" : value === "quarantined" ? "orange" : "red"}>{statusLabel(value)}</Tag> },
{ title: "密钥", width: 120, render: (_: unknown, item: OpsDevice) => <><Tag color={item.credential_status === "active" ? "blue" : "red"}>{item.credential_status}</Tag><br/><small>版本 {item.key_version}</small></> },
{ title: "当前固件", dataIndex: "firmware_version", width: 130, render: (value?: string) => value ? <Tag color="geekblue">{value}</Tag> : <Tag>未上报</Tag> },
{ title: "语音运行时", width: 170, render: (_: unknown, item: OpsDevice) => <><Tag color={item.voice_runtime === "volc_hardware_agent" ? "purple" : "default"}>{item.voice_runtime === "volc_hardware_agent" ? "火山硬件智能体" : "旧版 VoiceChat"}</Tag>{item.hardware_agent_transport ? <><br/><small>{item.hardware_agent_transport}</small></> : null}</> },
{ title: "最后上报", dataIndex: "last_reported_at", width: 170, render: (value?: string) => value ? new Date(value).toLocaleString() : "从未上报" },
{ title: "操作", fixed: "right", width: 320, render: (_: unknown, item: OpsDevice) => canOperate && item.status !== "revoked" ? <Space size="small">
{ title: "操作", fixed: "right", width: 440, render: (_: unknown, item: OpsDevice) => canOperate && item.status !== "revoked" ? <Space size="small" wrap>
<Button size="small" icon={<EditOutlined />} onClick={() => beginEdit(item)}>编辑</Button>
{!item.household_id && item.status === "registered" ? <Button size="small" icon={<KeyOutlined />} onClick={() => issueClaim(item)}>绑定码</Button> : null}
{!item.household_id && item.activation_state === "CLAIMABLE" ? <Button size="small" icon={<KeyOutlined />} onClick={() => issueClaim(item)}>绑定码</Button> : null}
{item.activation_state === "FACTORY" ? <Button size="small" onClick={() => transitionActivation(item, "CLAIMABLE", "放行出厂设备?", "运维确认设备已完成生产质检")}>出厂放行</Button> : null}
{item.activation_state === "BOUND" ? <Button size="small" onClick={() => transitionActivation(item, "PROVISIONING", "开始设备预配?", "运维启动运行凭据预配")}>开始预配</Button> : null}
{item.activation_state === "PROVISIONING" ? <Button size="small" onClick={() => transitionActivation(item, "ACTIVE", "确认设备已就绪?", "运维确认绑定与运行凭据均已就绪")}>完成激活</Button> : null}
{item.activation_state === "ACTIVE" ? <Button size="small" danger onClick={() => transitionActivation(item, "LOST", "标记设备丢失?", "运维确认设备丢失,立即隔离并暂停运行凭据")}>标记丢失</Button> : null}
{item.activation_state === "LOST" ? <Button size="small" onClick={() => transitionActivation(item, "ACTIVE", "恢复丢失设备?", "运维已核验设备归属与硬件身份")}>恢复设备</Button> : null}
<Button size="small" icon={<SafetyCertificateOutlined />} onClick={() => rotate(item)}>换密钥</Button>
<Popconfirm title="删除设备" description="设备将被吊销、密钥失效并解除当前归属;历史记录保留。" okText="确认删除" cancelText="取消" onConfirm={() => remove(item)}><Button size="small" danger icon={<DeleteOutlined />} /></Popconfirm>
</Space> : <span>只读</span> }
@@ -190,6 +224,9 @@ export function OpsDeviceWorkspace({ accessToken, accounts }: { accessToken: str
<Form.Item name="minimum_firmware_version" label="最低固件版本"><Input maxLength={80} /></Form.Item>
</Space>
<Form.Item name="assignment" label="所属家庭与长者"><Select allowClear placeholder="可暂不分配" options={assignmentOptions} showSearch optionFilterProp="label" /></Form.Item>
{!editing ? <Form.Item name="activation_state" label="初始激活状态" tooltip="出厂态设备不会生成用户绑定码,需质检完成后在列表中执行出厂放行。"><Select options={[{ value: "AUTO", label: "自动(未分配=待认领,已分配=已绑定)" }, { value: "FACTORY", label: "FACTORY(等待生产质检放行)" }]} /></Form.Item> : null}
<Form.Item name="voice_runtime" label="语音运行时" rules={[{ required: true }]}><Select options={[{ value: "legacy_voicechat", label: "旧版 RTC / VoiceChat" }, { value: "volc_hardware_agent", label: "火山硬件对话智能体" }]} /></Form.Item>
<Form.Item noStyle shouldUpdate={(previous, current) => previous.voice_runtime !== current.voice_runtime}>{({ getFieldValue }) => getFieldValue("voice_runtime") === "volc_hardware_agent" ? <Alert type="info" showIcon message="火山产品配置由平台统一管理" description="设备完成用户绑定后,首次预配会自动使用平台配置的 Instance ID、Product Key、默认 Bot、传输模式和最低 SDK,并建立设备运行绑定;创建设备时无需填写 Bot ID。" style={{ marginBottom: 18 }} /> : null}</Form.Item>
{editing ? <Form.Item name="status" label="设备状态"><Select options={[{ value: "registered", label: "已登记" }, { value: "quarantined", label: "已隔离" }]} /></Form.Item> : null}
</Form>
</Modal>
@@ -225,3 +262,7 @@ function showClaim(issue: DeviceClaimIssue, title = "一次性设备绑定码")
function statusLabel(value: string) {
return ({ registered: "已登记", quarantined: "已隔离", revoked: "已删除" } as Record<string, string>)[value] ?? value;
}
function activationColor(value: OpsDevice["activation_state"]) {
return ({ FACTORY: "default", CLAIMABLE: "cyan", BOUND: "blue", PROVISIONING: "gold", ACTIVE: "green", LOST: "red", REVOKED: "volcano" } as Record<string, string>)[value] ?? "default";
}
+256
View File
@@ -0,0 +1,256 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Button, Card, Checkbox, Form, Input, InputNumber, Modal, Popconfirm, Progress, Select, Space, Table, Tag, Typography, message } from "antd";
import { CloudUploadOutlined, DownloadOutlined, KeyOutlined, PauseCircleOutlined, PlayCircleOutlined, ReloadOutlined, RocketOutlined, StopOutlined } from "@ant-design/icons";
import { platformApi, type CurrentUser, type FirmwareRelease, type FirmwareRollout, type FirmwareSigningKey, type OpsDevice } from "../api/client";
type Account = CurrentUser["accounts"][number];
type ReleaseForm = {
model: string;
hardware_revision?: string;
version: string;
signature: string;
signing_key_id: string;
minimum_security_version?: string;
release_notes?: string;
release_channel: "STABLE" | "BETA" | "INTERNAL";
mandatory?: boolean;
};
type SigningKeyForm = { key_id: string; public_key_base64: string };
export function OpsFirmwareWorkspace({ accessToken, accounts }: { accessToken: string; accounts: Account[] }) {
const [tenantID, setTenantID] = useState(accounts[0]?.tenant_id ?? "");
const [releases, setReleases] = useState<FirmwareRelease[]>([]);
const [signingKeys, setSigningKeys] = useState<FirmwareSigningKey[]>([]);
const [devices, setDevices] = useState<OpsDevice[]>([]);
const [rollouts, setRollouts] = useState<FirmwareRollout[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const [releaseOpen, setReleaseOpen] = useState(false);
const [keyOpen, setKeyOpen] = useState(false);
const [rolloutRelease, setRolloutRelease] = useState<FirmwareRelease>();
const [rolloutName, setRolloutName] = useState("");
const [rolloutPercentage, setRolloutPercentage] = useState(100);
const [rolloutDeviceIDs, setRolloutDeviceIDs] = useState<string[]>([]);
const [firmwareFile, setFirmwareFile] = useState<File>();
const [revision, setRevision] = useState(0);
const [releaseForm] = Form.useForm<ReleaseForm>();
const [keyForm] = Form.useForm<SigningKeyForm>();
const account = accounts.find((item) => item.tenant_id === tenantID);
const canOperate = account?.account_kind === "operator" && ["ops_operator", "ops_admin"].includes(account.platform_role);
useEffect(() => {
if (!accounts.some((item) => item.tenant_id === tenantID)) setTenantID(accounts[0]?.tenant_id ?? "");
}, [accounts, tenantID]);
const refresh = useCallback(async (signal?: AbortSignal) => {
if (!tenantID) return;
setLoading(true); setError(undefined);
try {
const [releaseResponse, keyResponse, deviceResponse, rolloutResponse] = await Promise.all([
platformApi.opsFirmwareReleases(tenantID, accessToken, signal),
platformApi.opsFirmwareSigningKeys(tenantID, accessToken, signal),
platformApi.opsDevices(tenantID, accessToken, signal),
platformApi.opsFirmwareRollouts(tenantID, accessToken, signal)
]);
setReleases(releaseResponse.data.items);
setSigningKeys(keyResponse.data.items);
setDevices(deviceResponse.data.items);
setRollouts(rolloutResponse.data.items);
} catch (cause) {
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(cause instanceof Error ? cause.message : "固件数据读取失败");
} finally {
if (!signal?.aborted) setLoading(false);
}
}, [accessToken, tenantID]);
useEffect(() => {
const controller = new AbortController(); void refresh(controller.signal); return () => controller.abort();
}, [refresh, revision]);
const activeKeys = useMemo(() => signingKeys.filter((item) => item.status === "ACTIVE"), [signingKeys]);
const compatibleDevices = useMemo(() => !rolloutRelease ? [] : devices.filter((device) => device.status === "registered" && device.model === rolloutRelease.model && (!rolloutRelease.hardware_revision || device.hardware_revision === rolloutRelease.hardware_revision) && device.firmware_version !== rolloutRelease.version), [devices, rolloutRelease]);
function beginRelease() {
releaseForm.resetFields();
releaseForm.setFieldsValue({ model: "voice-terminal", release_channel: "STABLE", mandatory: false });
setFirmwareFile(undefined); setReleaseOpen(true);
}
async function publishRelease() {
const values = await releaseForm.validateFields();
if (!firmwareFile) { message.error("请选择固件文件"); return; }
setSaving(true);
try {
const digest = await sha256File(firmwareFile);
const prepared = await platformApi.prepareOpsFirmwareRelease(tenantID, {
model: values.model.trim(), hardware_revision: values.hardware_revision?.trim(), version: values.version.trim(),
expected_size: firmwareFile.size, expected_digest: digest, signature: values.signature.trim(), signing_key_id: values.signing_key_id,
minimum_security_version: values.minimum_security_version?.trim(), release_notes: values.release_notes?.trim(),
release_channel: values.release_channel, mandatory: Boolean(values.mandatory)
}, accessToken);
if (!prepared.data.upload_url) throw new Error("服务端未返回固件上传地址");
const uploaded = await fetch(prepared.data.upload_url, { method: "PUT", body: firmwareFile, headers: { "Content-Type": "application/octet-stream" } });
if (!uploaded.ok) throw new Error(`固件对象上传失败(HTTP ${uploaded.status})`);
await platformApi.finalizeOpsFirmwareRelease(tenantID, prepared.data.id, accessToken);
message.success("固件已上传并通过大小、SHA-256和Ed25519签名校验");
setReleaseOpen(false); setRevision((value) => value + 1);
} catch (cause) {
message.error(cause instanceof Error ? cause.message : "固件发布失败");
setRevision((value) => value + 1);
} finally { setSaving(false); }
}
async function registerKey() {
const values = await keyForm.validateFields(); setSaving(true);
try {
await platformApi.registerOpsFirmwareSigningKey(tenantID, { key_id: values.key_id.trim(), public_key_base64: values.public_key_base64.trim() }, accessToken);
message.success("可信签名公钥已登记"); setKeyOpen(false); keyForm.resetFields(); setRevision((value) => value + 1);
} catch (cause) { message.error(cause instanceof Error ? cause.message : "签名公钥登记失败"); }
finally { setSaving(false); }
}
async function transition(item: FirmwareRelease, action: "pause" | "resume" | "retire") {
try {
if (action === "pause") await platformApi.pauseOpsFirmwareRelease(tenantID, item.id, accessToken);
else if (action === "resume") await platformApi.resumeOpsFirmwareRelease(tenantID, item.id, accessToken);
else await platformApi.retireOpsFirmwareRelease(tenantID, item.id, accessToken);
message.success(action === "pause" ? "固件分发已暂停" : action === "resume" ? "固件分发已恢复" : "固件版本已退役");
setRevision((value) => value + 1);
} catch (cause) { message.error(cause instanceof Error ? cause.message : "固件状态变更失败"); }
}
async function download(item: FirmwareRelease) {
try {
const response = await platformApi.downloadOpsFirmwareRelease(tenantID, item.id, accessToken);
const anchor = document.createElement("a"); anchor.href = response.data.download_url; anchor.download = `firmware-${item.model}-${item.version}.bin`; anchor.rel = "noopener";
document.body.appendChild(anchor); anchor.click(); anchor.remove();
message.success(`下载地址有效至 ${new Date(response.data.expires_at).toLocaleTimeString()}`);
} catch (cause) { message.error(cause instanceof Error ? cause.message : "固件下载地址获取失败"); }
}
function resumeUpload(item: FirmwareRelease) {
const input = document.createElement("input"); input.type = "file"; input.accept = ".bin,.img,.fw,application/octet-stream";
input.onchange = async () => {
const file = input.files?.[0]; if (!file) return;
setSaving(true);
try {
const digest = await sha256File(file);
if (file.size !== item.expected_size || digest !== item.expected_digest) throw new Error("续传文件的大小或SHA-256与原版本记录不一致");
const renewed = await platformApi.renewOpsFirmwareUpload(tenantID, item.id, accessToken);
if (!renewed.data.upload_url) throw new Error("服务端未返回续传地址");
const uploaded = await fetch(renewed.data.upload_url, { method: "PUT", body: file, headers: { "Content-Type": "application/octet-stream" } });
if (!uploaded.ok) throw new Error(`固件对象续传失败(HTTP ${uploaded.status})`);
await platformApi.finalizeOpsFirmwareRelease(tenantID, item.id, accessToken);
message.success("固件续传和校验已完成"); setRevision((value) => value + 1);
} catch (cause) { message.error(cause instanceof Error ? cause.message : "固件续传失败"); }
finally { setSaving(false); }
};
input.click();
}
function beginRollout(item: FirmwareRelease) {
const candidates = devices.filter((device) => device.status === "registered" && device.model === item.model && (!item.hardware_revision || device.hardware_revision === item.hardware_revision) && device.firmware_version !== item.version);
setRolloutRelease(item); setRolloutName(`${item.model} ${item.version} OTA升级`); setRolloutPercentage(100); setRolloutDeviceIDs(candidates.map((device) => device.id));
}
async function startRollout() {
if (!rolloutRelease || !rolloutName.trim() || !rolloutDeviceIDs.length) { message.error("请填写批次名称并选择至少一台兼容设备"); return; }
setSaving(true);
try {
await platformApi.startOpsFirmwareRollout(tenantID, { release_id: rolloutRelease.id, name: rolloutName.trim(), target_percentage: rolloutPercentage, device_ids: rolloutDeviceIDs }, accessToken);
message.success("OTA升级批次已创建,可靠升级命令正在等待设备拉取"); setRolloutRelease(undefined); setRevision((value) => value + 1);
} catch (cause) { message.error(cause instanceof Error ? cause.message : "OTA升级批次创建失败"); }
finally { setSaving(false); }
}
return <div className="ops-firmware-workspace">
<Card>
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, flexWrap: "wrap" }}>
<div><strong>固件版本与可信制品</strong><br/><Typography.Text type="secondary">固件文件直传私有对象存储;发布前必须通过大小、SHA-256和Ed25519签名校验。</Typography.Text></div>
<Space wrap>
<Select value={tenantID} style={{ minWidth: 250 }} onChange={setTenantID} options={accounts.map((item) => ({ value: item.tenant_id, label: item.tenant_name }))} />
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => setRevision((value) => value + 1)}>刷新</Button>
{canOperate ? <Button icon={<KeyOutlined />} onClick={() => setKeyOpen(true)}>登记公钥</Button> : null}
{canOperate ? <Button type="primary" icon={<CloudUploadOutlined />} disabled={!activeKeys.length} onClick={beginRelease}>上传固件</Button> : null}
</Space>
</div>
</Card>
{!activeKeys.length ? <Alert style={{ marginTop: 16 }} type="warning" showIcon message="尚无活动固件签名公钥" description="请先登记由离线签名机、HSM或云KMS管理的Ed25519公钥,再上传固件。生产私钥不得进入平台。" /> : null}
{error ? <Alert style={{ marginTop: 16 }} type="error" showIcon message="固件数据读取失败" description={error} /> : null}
<Card style={{ marginTop: 16 }} title="固件版本" extra={<Tag>{releases.length} 个版本</Tag>}>
<Table rowKey="id" loading={loading} dataSource={releases} pagination={{ pageSize: 10 }} scroll={{ x: 1320 }} columns={[
{ title: "版本", width: 180, render: (_: unknown, item: FirmwareRelease) => <><strong>{item.version}</strong><br/><small>{item.model} / {item.hardware_revision || "通用硬件"}</small></> },
{ title: "通道", width: 110, render: (_: unknown, item: FirmwareRelease) => <><Tag color={item.release_channel === "STABLE" ? "green" : item.release_channel === "BETA" ? "gold" : "purple"}>{item.release_channel}</Tag>{item.mandatory ? <Tag color="red">强制</Tag> : null}</> },
{ title: "状态", dataIndex: "status", width: 110, render: (value: string) => <Tag color={value === "READY" ? "green" : value === "UPLOADING" ? "blue" : value === "PAUSED" ? "orange" : "default"}>{value}</Tag> },
{ title: "来源", dataIndex: "created_source", width: 90, render: (value: string) => value === "OPS" ? "维护端" : "家庭端" },
{ title: "文件", width: 170, render: (_: unknown, item: FirmwareRelease) => <><span>{formatBytes(item.expected_size)}</span><br/><Typography.Text copyable={{ text: item.expected_digest }} ellipsis style={{ width: 145 }}>{item.expected_digest}</Typography.Text></> },
{ title: "签名密钥", dataIndex: "signing_key_id", width: 150, ellipsis: true },
{ title: "创建时间", dataIndex: "created_at", width: 170, render: (value: string) => new Date(value).toLocaleString() },
{ title: "操作", fixed: "right", width: 400, render: (_: unknown, item: FirmwareRelease) => <Space size="small" wrap>
{item.status === "UPLOADING" && canOperate ? <Button size="small" loading={saving} icon={<CloudUploadOutlined />} onClick={() => resumeUpload(item)}>续传</Button> : null}
{item.verified_at ? <Button size="small" icon={<DownloadOutlined />} onClick={() => void download(item)}>下载</Button> : null}
{item.status === "READY" && canOperate ? <Button size="small" type="primary" ghost icon={<RocketOutlined />} disabled={!devices.some((device) => device.status === "registered" && device.model === item.model && (!item.hardware_revision || device.hardware_revision === item.hardware_revision) && device.firmware_version !== item.version)} onClick={() => beginRollout(item)}>发起OTA</Button> : null}
{item.status === "READY" && canOperate ? <Popconfirm title="暂停这个固件版本?" description="新下载和新灰度将被阻断,未投递命令会被取消。" onConfirm={() => void transition(item, "pause")}><Button size="small" icon={<PauseCircleOutlined />}>暂停</Button></Popconfirm> : null}
{item.status === "PAUSED" && canOperate ? <Popconfirm title="恢复这个固件版本?" description="旧批次不会自动恢复。" onConfirm={() => void transition(item, "resume")}><Button size="small" icon={<PlayCircleOutlined />}>恢复</Button></Popconfirm> : null}
{item.status !== "RETIRED" && canOperate ? <Popconfirm title="退役这个固件版本?" description="退役后不能再次分发;已验证制品和审计历史会保留。" onConfirm={() => void transition(item, "retire")}><Button size="small" danger icon={<StopOutlined />}>退役</Button></Popconfirm> : null}
</Space> }
]} />
</Card>
<Card style={{ marginTop: 16 }} title="OTA升级批次" extra={<Tag>{rollouts.length} 个批次</Tag>}>
<Table rowKey="id" size="small" dataSource={rollouts} pagination={{ pageSize: 8 }} columns={[
{ title: "批次", dataIndex: "name", render: (value: string, item: FirmwareRollout) => <><strong>{value}</strong><br/><Typography.Text type="secondary" copyable={{ text: item.id }}>{item.id.slice(0, 8)}</Typography.Text></> },
{ title: "状态", dataIndex: "status", width: 110, render: (value: string) => <Tag color={value === "RUNNING" ? "processing" : value === "COMPLETED" ? "green" : value === "PAUSED" ? "orange" : "default"}>{value}</Tag> },
{ title: "灰度", dataIndex: "target_percentage", width: 90, render: (value: number) => `${value}%` },
{ title: "进度", width: 230, render: (_: unknown, item: FirmwareRollout) => { const complete = item.succeeded + item.failed; const percent = item.targeted ? Math.round(complete / item.targeted * 100) : 0; return <><Progress percent={percent} size="small" status={item.failed ? "exception" : item.status === "COMPLETED" ? "success" : "active"} /><small>目标 {item.targeted} · 成功 {item.succeeded} · 失败 {item.failed}</small></>; } },
{ title: "创建时间", dataIndex: "created_at", width: 170, render: (value: string) => new Date(value).toLocaleString() },
{ title: "操作", width: 110, render: (_: unknown, item: FirmwareRollout) => item.status === "RUNNING" && canOperate ? <Popconfirm title="暂停这个OTA批次?" description="未投递升级命令会被取消,已下载或安装中的设备需要继续观察。" onConfirm={async () => { try { await platformApi.pauseOpsFirmwareRollout(tenantID, item.id, accessToken); message.success("OTA批次已暂停"); setRevision((value) => value + 1); } catch (cause) { message.error(cause instanceof Error ? cause.message : "OTA批次暂停失败"); } }}><Button size="small" icon={<PauseCircleOutlined />}>暂停</Button></Popconfirm> : "—" }
]} />
</Card>
<Card style={{ marginTop: 16 }} title="可信签名公钥">
<Table rowKey="key_id" size="small" dataSource={signingKeys} pagination={false} columns={[
{ title: "密钥编号", dataIndex: "key_id" },
{ title: "SHA-256指纹", dataIndex: "fingerprint_sha256", render: (value: string) => <Typography.Text copyable={{ text: value }} code>{value}</Typography.Text> },
{ title: "状态", dataIndex: "status", render: (value: string) => <Tag color={value === "ACTIVE" ? "green" : "default"}>{value}</Tag> },
{ title: "引用", render: (_: unknown, item: FirmwareSigningKey) => `上传 ${item.uploading_releases} / 就绪 ${item.ready_releases} / 运行 ${item.running_rollouts}` },
{ title: "操作", render: (_: unknown, item: FirmwareSigningKey) => item.status === "ACTIVE" && canOperate ? <Popconfirm title="吊销这个公钥?" description="吊销后不能用于新固件或新灰度。" onConfirm={async () => { try { await platformApi.revokeOpsFirmwareSigningKey(tenantID, item.key_id, accessToken); message.success("公钥已吊销"); setRevision((value) => value + 1); } catch (cause) { message.error(cause instanceof Error ? cause.message : "公钥吊销失败"); } }}><Button size="small" danger>吊销</Button></Popconfirm> : "—" }
]} />
</Card>
<Modal open={releaseOpen} title="上传并验证固件" width={720} okText="上传并发布" cancelText="取消" confirmLoading={saving} onOk={() => void publishRelease()} onCancel={() => setReleaseOpen(false)}>
<Alert type="info" showIcon message="请先在离线环境对 firmware-manifest-v1 清单完成Ed25519签名" description="浏览器会计算文件SHA-256,服务端会重新校验对象大小、摘要和签名。" />
<Form form={releaseForm} layout="vertical" style={{ marginTop: 16 }}>
<Space align="start" wrap><Form.Item name="model" label="设备型号" rules={[{ required: true }]}><Input style={{ width: 210 }} /></Form.Item><Form.Item name="hardware_revision" label="硬件版本"><Input style={{ width: 180 }} placeholder="留空表示通用" /></Form.Item><Form.Item name="version" label="固件版本" rules={[{ required: true }]}><Input style={{ width: 160 }} placeholder="例如 1.4.2" /></Form.Item></Space>
<Space align="start" wrap><Form.Item name="signing_key_id" label="签名密钥" rules={[{ required: true }]}><Select style={{ width: 220 }} options={activeKeys.map((item) => ({ value: item.key_id, label: item.key_id }))} /></Form.Item><Form.Item name="release_channel" label="发布通道" rules={[{ required: true }]}><Select style={{ width: 150 }} options={[{ value: "STABLE", label: "稳定" }, { value: "BETA", label: "Beta" }, { value: "INTERNAL", label: "内部" }]} /></Form.Item><Form.Item name="minimum_security_version" label="最低安全版本"><Input style={{ width: 180 }} /></Form.Item></Space>
<Form.Item name="signature" label="Ed25519签名(Base64)" rules={[{ required: true }]}><Input.TextArea rows={3} /></Form.Item>
<Form.Item label="固件文件" required><input type="file" accept=".bin,.img,.fw,application/octet-stream" onChange={(event) => setFirmwareFile(event.target.files?.[0])} /></Form.Item>
<Form.Item name="release_notes" label="发布说明"><Input.TextArea rows={3} maxLength={2000} showCount /></Form.Item>
<Form.Item name="mandatory" valuePropName="checked"><Checkbox>标记为强制升级版本</Checkbox></Form.Item>
</Form>
</Modal>
<Modal open={keyOpen} title="登记固件可信签名公钥" okText="登记公钥" cancelText="取消" confirmLoading={saving} onOk={() => void registerKey()} onCancel={() => setKeyOpen(false)}>
<Alert type="warning" showIcon message="只登记公钥" description="生产私钥必须保存在离线签名机、HSM或云KMS中,不能粘贴到本页面。" />
<Form form={keyForm} layout="vertical" style={{ marginTop: 16 }}><Form.Item name="key_id" label="密钥编号" rules={[{ required: true }, { max: 120 }]}><Input /></Form.Item><Form.Item name="public_key_base64" label="Ed25519公钥(32字节标准Base64)" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item></Form>
</Modal>
<Modal open={Boolean(rolloutRelease)} title="发起OTA升级" okText="创建升级批次" cancelText="取消" confirmLoading={saving} onOk={() => void startRollout()} onCancel={() => setRolloutRelease(undefined)} width={680}>
{rolloutRelease ? <Alert type="info" showIcon message={`${rolloutRelease.model} ${rolloutRelease.version}`} description={`只列出型号和硬件版本兼容、当前版本不同且状态为已登记的设备。实际目标按设备编号稳定哈希命中 ${rolloutPercentage}%。`} /> : null}
<Space direction="vertical" size="middle" style={{ width: "100%", marginTop: 16 }}>
<label>批次名称<Input value={rolloutName} maxLength={160} onChange={(event) => setRolloutName(event.target.value)} /></label>
<label>灰度比例<br/><InputNumber min={1} max={100} value={rolloutPercentage} onChange={(value) => setRolloutPercentage(value ?? 100)} addonAfter="%" style={{ width: 180 }} /></label>
<label>目标设备<Select mode="multiple" value={rolloutDeviceIDs} onChange={setRolloutDeviceIDs} style={{ width: "100%" }} placeholder="选择兼容设备" options={compatibleDevices.map((device) => ({ value: device.id, label: `${device.id.slice(0, 8)} · 当前 ${device.firmware_version || "未上报"}` }))} /></label>
{!compatibleDevices.length ? <Alert type="warning" showIcon message="没有可升级的兼容设备" /> : null}
</Space>
</Modal>
</div>;
}
async function sha256File(file: File) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
+2 -2
View File
@@ -1634,7 +1634,7 @@ function customerSectionDescription(section: Tab) {
}
function eventTypeLabel(type: string) {
return ({ "device.presence.changed": "设备在线状态变化", "conversation.started": "通话已开始", "conversation.completed": "通话已完成", "conversation.failed": "通话启动失败", "story.created": "生成新的故事候选", "story.revised": "故事内容已更新", "media.upload.completed": "通话媒体上传完成", "export.ready": "隐私导出已就绪", "privacy_request.updated": "隐私请求状态变化", "device.command.updated": "设备指令状态变化" } as Record<string, string>)[type] ?? "业务状态已更新";
return ({ "device.presence.changed": "设备在线状态变化", "device.binding.reset": "设备已恢复出厂并解除绑定", "conversation.started": "通话已开始", "conversation.completed": "通话已完成", "conversation.failed": "通话启动失败", "story.created": "生成新的故事候选", "story.revised": "故事内容已更新", "media.upload.completed": "通话媒体上传完成", "export.ready": "隐私导出已就绪", "privacy_request.updated": "隐私请求状态变化", "device.command.updated": "设备指令状态变化" } as Record<string, string>)[type] ?? "业务状态已更新";
}
function resourceTypeLabel(type: string) {
@@ -2146,7 +2146,7 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
{detailOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}><section className="conversation-detail workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby="conversation-detail-title"><header><div><h3 id="conversation-detail-title">通话详情</h3><p>查看文字内容、整理结果、语音授权与状态历史</p></div><button type="button" aria-label="关闭" onClick={() => setDetailOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">{detailLoading ? <StatePanel title="正在读取通话详情" detail="正在核验状态、最终话轮、整理结果、用量和录音访问权限…" /> : detailError && selectedConversationID ? <StatePanel title="通话详情读取失败" detail={detailError} action={() => void onOpen(selectedConversationID)} /> : detail ? <>
<div className="resource-detail__head"><div><p className="eyebrow">CONVERSATION DETAIL</p><h3>{conversationStatusLabel(detail.status)}</h3><p>{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}</p></div></div>
{detail.failure_code ? <p className="recording-notice recording-notice--error" role="alert"><strong>{conversationFailureLabel(detail.failure_code) ?? "通话准备失败"}</strong><br />设备收到的失败标识:{detail.failure_code};背景准备状态:{detail.snapshots.context_preparation_status}</p> : null}
<div className="detail-grid"><Info label="语音处理" value={engineModeLabel(detail.engine_mode)} /><Info label="保存策略" value={retentionModeLabel(detail.retention_mode)} /><Info label="授权快照" value={`v${detail.snapshots.consent_snapshot_version}`} /><Info label="档案快照" value={`v${detail.snapshots.profile_version}`} /></div>
<div className="detail-grid"><Info label="语音运行时" value={detail.runtime_type === "volc_hardware_agent" ? "火山硬件对话智能体" : "平台 RTC / VoiceChat"} /><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}`} />{detail.hardware_agent ? <Info label="硬件链路" value={`${detail.hardware_agent.transport === "high_quality" ? "高质量" : "低负载"} · ${detail.hardware_agent.status}`} /> : null}</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}