chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
import type { components, paths } from "./schema";
|
||||
|
||||
type LivenessResponse = paths["/health/live"]["get"]["responses"]["200"]["content"]["application/json"];
|
||||
type ReadinessResponse = paths["/health/ready"]["get"]["responses"]["200"]["content"]["application/json"];
|
||||
type VersionResponse = paths["/api/v1/version"]["get"]["responses"]["200"]["content"]["application/json"];
|
||||
type CurrentUserResponse = paths["/api/v1/me"]["get"]["responses"]["200"]["content"]["application/json"];
|
||||
export type CurrentUser = components["schemas"]["CurrentUser"];
|
||||
export type Household = components["schemas"]["Household"];
|
||||
export type HouseholdMember = components["schemas"]["HouseholdMember"];
|
||||
export type MemberInvitation = components["schemas"]["MemberInvitation"];
|
||||
export type MemberInvitationPreview = components["schemas"]["MemberInvitationPreview"];
|
||||
export type Subject = components["schemas"]["Subject"];
|
||||
export type SubjectFact = components["schemas"]["SubjectFact"];
|
||||
export type Consent = components["schemas"]["Consent"];
|
||||
export type ConversationReadiness = components["schemas"]["ConversationReadiness"];
|
||||
export type ManagedDevice = components["schemas"]["ManagedDevice"];
|
||||
export type DevicePresence = components["schemas"]["DevicePresence"];
|
||||
export type DeviceTwin = components["schemas"]["DeviceTwin"];
|
||||
export type DeviceCommand = components["schemas"]["DeviceCommand"];
|
||||
export type FirmwareSigningKey = components["schemas"]["FirmwareSigningKey"];
|
||||
export type FirmwareRelease = components["schemas"]["FirmwareRelease"];
|
||||
export type FirmwareReleaseImpact = components["schemas"]["FirmwareReleaseImpact"];
|
||||
export type FirmwareRollout = components["schemas"]["FirmwareRollout"];
|
||||
export type FirmwareAssignment = components["schemas"]["FirmwareAssignment"];
|
||||
export type FirmwareAssignmentEvent = components["schemas"]["FirmwareAssignmentEvent"];
|
||||
export type ConversationSummary = components["schemas"]["ConversationSummary"];
|
||||
export type ConversationDetail = components["schemas"]["ConversationDetail"];
|
||||
export type ConversationUsage = components["schemas"]["ConversationUsage"];
|
||||
export type ConversationTurn = components["schemas"]["ConversationTurn"];
|
||||
export type ConversationContentSummary = components["schemas"]["ConversationContentSummary"];
|
||||
export type ConversationMedia = components["schemas"]["ConversationMedia"];
|
||||
export type MediaSegment = components["schemas"]["MediaSegment"];
|
||||
export type RecordingAccessGrant = components["schemas"]["RecordingAccessGrant"];
|
||||
export type MediaAccess = components["schemas"]["MediaAccess"];
|
||||
export type Story = components["schemas"]["Story"];
|
||||
export type CuratedMemory = components["schemas"]["CuratedMemory"];
|
||||
export type PrivacyRequest = components["schemas"]["PrivacyRequest"];
|
||||
export type ExportTask = components["schemas"]["ExportTask"];
|
||||
export type SharingGrant = components["schemas"]["SharingGrant"];
|
||||
export type OpsConfigVersion = components["schemas"]["OpsConfigVersion"];
|
||||
export type OpsConfigEvaluation = components["schemas"]["OpsConfigEvaluation"];
|
||||
export type OpsResourceReference = components["schemas"]["OpsResourceReference"];
|
||||
export type OpsDeviceGroup = components["schemas"]["OpsDeviceGroup"];
|
||||
export type OpsConfigRollout = components["schemas"]["OpsConfigRollout"];
|
||||
export type OpsConfigTransitionRequest = components["schemas"]["OpsConfigTransitionRequest"];
|
||||
export type BillingPriceBook = components["schemas"]["BillingPriceBook"];
|
||||
export type QuotaPolicy = components["schemas"]["QuotaPolicy"];
|
||||
export type CostSummary = components["schemas"]["CostSummary"];
|
||||
export type AlertRoutingPolicy = components["schemas"]["AlertRoutingPolicy"];
|
||||
export type AlertDeliveryTest = components["schemas"]["AlertDeliveryTest"];
|
||||
export type AlertIncident = components["schemas"]["AlertIncident"];
|
||||
export type AlertSilence = components["schemas"]["AlertSilence"];
|
||||
export type OpsDiagnosticRecord = components["schemas"]["OpsDiagnosticRecord"];
|
||||
export type OpsExternalDependency = components["schemas"]["OpsExternalDependency"];
|
||||
export type OpsDiagnosticAction = components["schemas"]["OpsDiagnosticAction"];
|
||||
export type OpsDiagnosticJob = components["schemas"]["OpsDiagnosticJob"];
|
||||
export type OpsDiagnosticBundle = components["schemas"]["OpsDiagnosticBundle"];
|
||||
export type OpsAcceptanceSnapshot = components["schemas"]["OpsAcceptanceSnapshot"];
|
||||
export type OpsAcceptanceSnapshotRequest = components["schemas"]["OpsAcceptanceSnapshotRequest"];
|
||||
export type OpsPilotDailySnapshot = components["schemas"]["OpsPilotDailySnapshot"];
|
||||
export type OpsPilotDailySnapshotRequest = components["schemas"]["OpsPilotDailySnapshotRequest"];
|
||||
export type OpsPilotWeeklySnapshot = components["schemas"]["OpsPilotWeeklySnapshot"];
|
||||
export type OpsPilotWeeklySnapshotRequest = components["schemas"]["OpsPilotWeeklySnapshotRequest"];
|
||||
export type IntegrationIssue = components["schemas"]["IntegrationIssue"];
|
||||
export type IntegrationIssueSummary = components["schemas"]["IntegrationIssueSummary"];
|
||||
export type OpsSensitiveAccessRequest = components["schemas"]["OpsSensitiveAccessRequest"];
|
||||
export type OpsSensitiveConversation = components["schemas"]["OpsSensitiveConversation"];
|
||||
export type OpsSensitiveRecordingAccess = components["schemas"]["OpsSensitiveRecordingAccess"];
|
||||
export type CreateSubjectRequest = components["schemas"]["CreateSubjectRequest"];
|
||||
export type UpdateSubjectRequest = components["schemas"]["UpdateSubjectRequest"];
|
||||
export type CreateSubjectFactRequest = components["schemas"]["CreateSubjectFactRequest"];
|
||||
export type GrantConsentRequest = components["schemas"]["GrantConsentRequest"];
|
||||
export type PrepareFirmwareReleaseRequest = components["schemas"]["PrepareFirmwareReleaseRequest"];
|
||||
export type DeviceClaimRequest = components["schemas"]["DeviceClaimRequest"];
|
||||
export type UpdateDeviceTwinRequest = components["schemas"]["UpdateDeviceTwinRequest"];
|
||||
export type CreateMemberInvitationRequest = components["schemas"]["CreateMemberInvitationRequest"];
|
||||
|
||||
export type ConversationQuery = {
|
||||
status?: components["schemas"]["ConversationStatus"];
|
||||
engine_mode?: "s2s_pure" | "s2s_hybrid" | "cascade";
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
type Envelope<T> = { request_id: string; data: T };
|
||||
const requestTimeoutMS = 15_000;
|
||||
export const AUTHENTICATION_REQUIRED_EVENT = "platform:authentication-required";
|
||||
|
||||
export class APIRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly code?: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function getJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
const response = await fetchWithTimeout(path, {
|
||||
headers: { Accept: "application/json" }
|
||||
}, signal);
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败(HTTP ${response.status})`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export const platformApi = {
|
||||
liveness: (signal?: AbortSignal) => getJSON<LivenessResponse>("/health/live", signal),
|
||||
readiness: (signal?: AbortSignal) => getJSON<ReadinessResponse>("/health/ready", signal),
|
||||
version: (signal?: AbortSignal) => getJSON<VersionResponse>("/api/v1/version", signal),
|
||||
currentUser: (accessToken: string, signal?: AbortSignal) =>
|
||||
getJSONWithToken<CurrentUserResponse>("/api/v1/me", accessToken, signal),
|
||||
opsConfigVersions: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsConfigVersion[] }>>("GET", "/api/v1/ops/config-versions", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsConfigVersion: (tenantID: string, input: components["schemas"]["CreateOpsConfigVersionRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsConfigVersion>>("POST", "/api/v1/ops/config-versions", accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-config-${crypto.randomUUID()}` }),
|
||||
submitOpsConfigVersion: (tenantID: string, item: OpsConfigVersion, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsConfigVersion>>("PATCH", `/api/v1/ops/config-versions/${item.id}/submit`, accessToken, undefined, undefined,
|
||||
{ "X-Tenant-Id": tenantID, "If-Match": `"v${item.row_version}"` }),
|
||||
publishOpsConfigVersion: (tenantID: string, item: OpsConfigVersion, input: OpsConfigTransitionRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsConfigVersion>>("PATCH", `/api/v1/ops/config-versions/${item.id}/publish`, accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "If-Match": `"v${item.row_version}"` }),
|
||||
rollbackOpsConfigVersion: (tenantID: string, item: OpsConfigVersion, input: OpsConfigTransitionRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsConfigVersion>>("POST", `/api/v1/ops/config-versions/${item.id}/rollback`, accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-config-rollback-${crypto.randomUUID()}` }),
|
||||
opsConfigEvaluations: (tenantID: string, configID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsConfigEvaluation[] }>>("GET", `/api/v1/ops/config-versions/${configID}/evaluations`, accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsConfigEvaluation: (tenantID: string, configID: string, input: components["schemas"]["CreateOpsConfigEvaluationRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsConfigEvaluation>>("POST", `/api/v1/ops/config-versions/${configID}/evaluations`, accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-config-evaluation-${crypto.randomUUID()}` }),
|
||||
opsResourceReferences: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsResourceReference[] }>>("GET", "/api/v1/ops/resource-references", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsResourceReference: (tenantID: string, input: components["schemas"]["CreateOpsResourceReferenceRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsResourceReference>>("POST", "/api/v1/ops/resource-references", accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-resource-reference-${crypto.randomUUID()}` }),
|
||||
revokeOpsResourceReference: (tenantID: string, item: OpsResourceReference, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsResourceReference>>("PATCH", `/api/v1/ops/resource-references/${item.id}/revoke`, accessToken, undefined, { reason },
|
||||
{ "X-Tenant-Id": tenantID, "If-Match": `"v${item.row_version}"` }),
|
||||
opsDeviceGroups: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsDeviceGroup[] }>>("GET", "/api/v1/ops/device-groups", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsDeviceGroup: (tenantID: string, input: components["schemas"]["CreateOpsDeviceGroupRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsDeviceGroup>>("POST", "/api/v1/ops/device-groups", accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-device-group-${crypto.randomUUID()}` }),
|
||||
opsConfigRollouts: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsConfigRollout[] }>>("GET", "/api/v1/ops/config-rollouts", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
pauseOpsConfigRollout: (tenantID: string, item: OpsConfigRollout, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsConfigRollout>>("PATCH", `/api/v1/ops/config-rollouts/${item.id}/pause`, accessToken, undefined, { reason },
|
||||
{ "X-Tenant-Id": tenantID, "If-Match": `"v${item.row_version}"` }),
|
||||
resumeOpsConfigRollout: (tenantID: string, item: OpsConfigRollout, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsConfigRollout>>("PATCH", `/api/v1/ops/config-rollouts/${item.id}/resume`, accessToken, undefined, { reason },
|
||||
{ "X-Tenant-Id": tenantID, "If-Match": `"v${item.row_version}"` }),
|
||||
opsPriceBooks: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: BillingPriceBook[] }>>("GET", "/api/v1/ops/price-books", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsPriceBook: (tenantID: string, input: components["schemas"]["CreateBillingPriceBookRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<BillingPriceBook>>("POST", "/api/v1/ops/price-books", accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `billing-price-${crypto.randomUUID()}` }),
|
||||
publishOpsPriceBook: (tenantID: string, item: BillingPriceBook, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<BillingPriceBook>>("PATCH", `/api/v1/ops/price-books/${item.id}/publish`, accessToken, undefined, { reason },
|
||||
{ "X-Tenant-Id": tenantID, "If-Match": `"v${item.row_version}"` }),
|
||||
opsQuotaPolicies: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: QuotaPolicy[] }>>("GET", "/api/v1/ops/quota-policies", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsQuotaPolicy: (tenantID: string, input: components["schemas"]["CreateQuotaPolicyRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<QuotaPolicy>>("POST", "/api/v1/ops/quota-policies", accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `usage-quota-${crypto.randomUUID()}` }),
|
||||
opsCostSummary: (tenantID: string, from: string, to: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<CostSummary>>("GET", `/api/v1/ops/cost-summary?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
opsAlertRoutingPolicies: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: AlertRoutingPolicy[] }>>("GET", "/api/v1/ops/alert-routing-policies", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsAlertRoutingPolicy: (tenantID: string, input: components["schemas"]["CreateAlertRoutingPolicyRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<AlertRoutingPolicy>>("POST", "/api/v1/ops/alert-routing-policies", accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `alert-route-${crypto.randomUUID()}` }),
|
||||
opsAlertDeliveryTests: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: AlertDeliveryTest[] }>>("GET", "/api/v1/ops/alert-delivery-tests", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsAlertDeliveryTest: (tenantID: string, routingPolicyID: string, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<AlertDeliveryTest>>("POST", `/api/v1/ops/alert-routing-policies/${routingPolicyID}/delivery-tests`, accessToken, undefined, { reason },
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `alert-delivery-test-${crypto.randomUUID()}` }),
|
||||
opsAlerts: (tenantID: string, status: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: AlertIncident[] }>>("GET", `/api/v1/ops/alerts${status ? `?status=${encodeURIComponent(status)}` : ""}`, accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
opsAlert: (tenantID: string, alertID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<AlertIncident>>("GET", `/api/v1/ops/alerts/${alertID}`, accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
transitionOpsAlert: (tenantID: string, item: AlertIncident, action: "acknowledge" | "mitigate" | "resolve", note: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<AlertIncident>>("POST", `/api/v1/ops/alerts/${item.id}/${action}`, accessToken, undefined, { note },
|
||||
{ "X-Tenant-Id": tenantID, "If-Match": `"v${item.row_version}"` }),
|
||||
opsAlertSilences: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: AlertSilence[] }>>("GET", "/api/v1/ops/alert-silences", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsAlertSilence: (tenantID: string, input: components["schemas"]["CreateAlertSilenceRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<AlertSilence>>("POST", "/api/v1/ops/alert-silences", accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `alert-silence-${crypto.randomUUID()}` }),
|
||||
revokeOpsAlertSilence: (tenantID: string, item: AlertSilence, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<AlertSilence>>("POST", `/api/v1/ops/alert-silences/${item.id}/revoke`, accessToken, undefined, { reason },
|
||||
{ "X-Tenant-Id": tenantID, "If-Match": `"v${item.row_version}"` }),
|
||||
opsIntegrationIssues: (tenantID: string, status: components["schemas"]["IntegrationIssueStatus"] | "", accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: IntegrationIssue[]; summary: IntegrationIssueSummary }>>("GET", `/api/v1/ops/integration-issues${status ? `?status=${encodeURIComponent(status)}` : ""}`, accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsIntegrationIssue: (tenantID: string, input: components["schemas"]["CreateIntegrationIssueRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<IntegrationIssue>>("POST", "/api/v1/ops/integration-issues", accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-integration-issue-${crypto.randomUUID()}` }),
|
||||
updateOpsIntegrationIssue: (tenantID: string, item: IntegrationIssue, input: components["schemas"]["UpdateIntegrationIssueRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<IntegrationIssue>>("PATCH", `/api/v1/ops/integration-issues/${item.id}`, accessToken, undefined, input,
|
||||
{ "X-Tenant-Id": tenantID, "If-Match": `"v${item.version}"` }),
|
||||
searchOpsDiagnostics: (tenantID: string, field: "device_id" | "conversation_id" | "room_id" | "provider_task_ref" | "request_id", value: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsDiagnosticRecord[] }>>("GET", `/api/v1/ops/diagnostics/search?field=${encodeURIComponent(field)}&value=${encodeURIComponent(value)}`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
|
||||
opsExternalDependencies: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsExternalDependency[] }>>("GET", "/api/v1/ops/diagnostics/external-dependencies", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
stopOpsDiagnosticConversation: (tenantID: string, conversationID: string, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsDiagnosticAction>>("POST", `/api/v1/ops/diagnostics/conversations/${conversationID}/stop`, accessToken, undefined, { reason }, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-diagnostic-stop-${crypto.randomUUID()}` }),
|
||||
reconcileOpsDiagnosticConversation: (tenantID: string, conversationID: string, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsDiagnosticAction>>("POST", `/api/v1/ops/diagnostics/conversations/${conversationID}/reconcile`, accessToken, undefined, { reason }, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-diagnostic-reconcile-${crypto.randomUUID()}` }),
|
||||
refreshOpsDiagnosticDeviceConfig: (tenantID: string, deviceID: string, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsDiagnosticAction>>("POST", `/api/v1/ops/diagnostics/devices/${deviceID}/config-refresh`, accessToken, undefined, { reason }, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-diagnostic-refresh-${crypto.randomUUID()}` }),
|
||||
revokeOpsDiagnosticDevice: (tenantID: string, deviceID: string, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsDiagnosticAction>>("POST", `/api/v1/ops/diagnostics/devices/${deviceID}/revoke`, accessToken, undefined, { reason }, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-diagnostic-revoke-${crypto.randomUUID()}` }),
|
||||
opsDiagnosticJobs: (tenantID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsDiagnosticJob[] }>>("GET", "/api/v1/ops/diagnostics/jobs", accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
|
||||
retryOpsDiagnosticJob: (tenantID: string, jobID: string, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsDiagnosticAction>>("POST", `/api/v1/ops/diagnostics/jobs/${jobID}/retry`, accessToken, undefined, { reason }, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-diagnostic-job-retry-${crypto.randomUUID()}` }),
|
||||
exportOpsDiagnosticBundle: (tenantID: string, field: "device_id" | "conversation_id" | "room_id" | "provider_task_ref" | "request_id", value: string, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsDiagnosticBundle>>("POST", "/api/v1/ops/diagnostics/bundles", accessToken, undefined, { field, value, reason }, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-diagnostic-bundle-${crypto.randomUUID()}` }),
|
||||
exportOpsAcceptanceSnapshot: (tenantID: string, input: OpsAcceptanceSnapshotRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsAcceptanceSnapshot>>("POST", "/api/v1/ops/diagnostics/acceptance-snapshots", accessToken, undefined, input, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-acceptance-snapshot-${crypto.randomUUID()}` }),
|
||||
exportOpsPilotDailySnapshot: (tenantID: string, input: OpsPilotDailySnapshotRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsPilotDailySnapshot>>("POST", "/api/v1/ops/diagnostics/pilot-daily-snapshots", accessToken, undefined, input, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-pilot-daily-${crypto.randomUUID()}` }),
|
||||
exportOpsPilotWeeklySnapshot: (tenantID: string, input: OpsPilotWeeklySnapshotRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsPilotWeeklySnapshot>>("POST", "/api/v1/ops/diagnostics/pilot-weekly-snapshots", accessToken, undefined, input, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-pilot-weekly-${crypto.randomUUID()}` }),
|
||||
opsSensitiveAccessRequests: (tenantID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsSensitiveAccessRequest[] }>>("GET", "/api/v1/ops/sensitive-access-requests", accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsSensitiveAccessRequest: (tenantID: string, conversationID: string, capabilities: ("TRANSCRIPT_READ" | "RECORDING_PLAY")[], reason: string, expiresAt: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsSensitiveAccessRequest>>("POST", "/api/v1/ops/sensitive-access-requests", accessToken, undefined, { conversation_id: conversationID, capabilities, reason, expires_at: expiresAt }, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-sensitive-access-${crypto.randomUUID()}` }),
|
||||
reviewOpsSensitiveAccessRequest: (tenantID: string, requestID: string, decision: "APPROVE" | "REJECT", reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsSensitiveAccessRequest>>("POST", `/api/v1/ops/sensitive-access-requests/${requestID}/review`, accessToken, undefined, { decision, reason }, { "X-Tenant-Id": tenantID, "Idempotency-Key": `ops-sensitive-review-${crypto.randomUUID()}` }),
|
||||
opsSensitiveConversation: (tenantID: string, conversationID: string, grantID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsSensitiveConversation>>("GET", `/api/v1/ops/sensitive-conversations/${conversationID}`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID, "X-Ops-Access-Grant": grantID }),
|
||||
createOpsSensitiveRecordingAccess: (tenantID: string, segmentID: string, grantID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsSensitiveRecordingAccess>>("POST", `/api/v1/ops/sensitive-media/${segmentID}/play`, accessToken, undefined, { grant_id: grantID }, { "X-Tenant-Id": tenantID }),
|
||||
households: (accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: Household[] }>>("GET", "/api/v1/households", accessToken, signal),
|
||||
createHousehold: (tenantID: string, name: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<Household>>("POST", "/api/v1/households", accessToken, undefined, { name }, { "X-Tenant-Id": tenantID }),
|
||||
subjects: (householdID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: Subject[] }>>("GET", `/api/v1/households/${householdID}/subjects`, accessToken, signal),
|
||||
facts: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: SubjectFact[] }>>("GET", `/api/v1/subjects/${subjectID}/facts`, accessToken, signal),
|
||||
consents: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: Consent[] }>>("GET", `/api/v1/subjects/${subjectID}/consents`, accessToken, signal),
|
||||
conversationReadiness: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<ConversationReadiness>>("GET", `/api/v1/subjects/${subjectID}/conversation-readiness`, accessToken, signal),
|
||||
conversations: (subjectID: string, query: ConversationQuery, accessToken: string, signal?: AbortSignal) => {
|
||||
const parameters = new URLSearchParams({ limit: String(query.limit ?? 20) });
|
||||
if (query.status) parameters.set("status", query.status);
|
||||
if (query.engine_mode) parameters.set("engine_mode", query.engine_mode);
|
||||
if (query.from) parameters.set("from", query.from);
|
||||
if (query.to) parameters.set("to", query.to);
|
||||
if (query.cursor) parameters.set("cursor", query.cursor);
|
||||
return authenticatedJSON<Envelope<{ items: ConversationSummary[]; next_cursor?: string }>>(
|
||||
"GET",
|
||||
`/api/v1/subjects/${subjectID}/conversations?${parameters.toString()}`,
|
||||
accessToken,
|
||||
signal
|
||||
);
|
||||
},
|
||||
conversation: (conversationID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<ConversationDetail>>("GET", `/api/v1/conversations/${conversationID}`, accessToken, signal),
|
||||
conversationUsage: (conversationID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<ConversationUsage>>("GET", `/api/v1/conversations/${conversationID}/usage`, accessToken, signal),
|
||||
conversationTurns: (conversationID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: ConversationTurn[]; next_cursor?: string }>>("GET", `/api/v1/conversations/${conversationID}/turns?limit=100`, accessToken, signal),
|
||||
conversationSummaries: (conversationID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: ConversationContentSummary[] }>>("GET", `/api/v1/conversations/${conversationID}/summaries`, accessToken, signal),
|
||||
conversationMedia: (conversationID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<ConversationMedia>>("GET", `/api/v1/conversations/${conversationID}/media`, accessToken, signal),
|
||||
recordingAccessGrants: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: RecordingAccessGrant[] }>>("GET", `/api/v1/subjects/${subjectID}/recording-access-grants`, accessToken, signal),
|
||||
grantRecordingAccess: (subjectID: string, input: { grantee_user_id: string; permission: "PLAY" | "DOWNLOAD"; purpose: string; expires_at: string }, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<RecordingAccessGrant>>("POST", `/api/v1/subjects/${subjectID}/recording-access-grants`, accessToken, undefined, input),
|
||||
revokeRecordingAccess: (grantID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<RecordingAccessGrant>>("POST", `/api/v1/recording-access-grants/${grantID}/revoke`, accessToken),
|
||||
createMediaAccess: (segmentID: string, action: "PLAY" | "DOWNLOAD", purpose: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<MediaAccess>>("POST", `/api/v1/media-segments/${segmentID}/access`, accessToken, undefined, { action, purpose }),
|
||||
sharingGrants: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: SharingGrant[] }>>("GET", `/api/v1/sharing-grants?subject_id=${encodeURIComponent(subjectID)}`, accessToken, signal),
|
||||
createSharingGrant: (input: components["schemas"]["CreateSharingGrantRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<SharingGrant>>("POST", "/api/v1/sharing-grants", accessToken, undefined, input,
|
||||
{ "Idempotency-Key": `sharing-grant-${crypto.randomUUID()}` }),
|
||||
revokeSharingGrant: (grantID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<SharingGrant>>("DELETE", `/api/v1/sharing-grants/${grantID}`, accessToken),
|
||||
stories: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: Story[] }>>("GET", `/api/v1/subjects/${subjectID}/stories`, accessToken, signal),
|
||||
story: (storyID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<Story>>("GET", `/api/v1/stories/${storyID}`, accessToken, signal),
|
||||
reviseStory: (story: Story, content: string, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<Story>>("PATCH", `/api/v1/stories/${story.id}`, accessToken, undefined,
|
||||
{ content, reason }, { "If-Match": `"v${story.version}"` }),
|
||||
confirmStory: (story: Story, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<{ story: Story; memory: CuratedMemory }>>("POST", `/api/v1/stories/${story.id}/confirm`, accessToken, undefined,
|
||||
{ reason }, { "If-Match": `"v${story.version}"` }),
|
||||
rejectStory: (story: Story, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<Story>>("POST", `/api/v1/stories/${story.id}/reject`, accessToken, undefined,
|
||||
{ reason }, { "If-Match": `"v${story.version}"` }),
|
||||
memories: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: CuratedMemory[] }>>("GET", `/api/v1/subjects/${subjectID}/memories`, accessToken, signal),
|
||||
updateMemory: (memory: CuratedMemory, input: { content?: string; status?: "ACTIVE" | "DISABLED"; reason: string }, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<CuratedMemory>>("PATCH", `/api/v1/memories/${memory.id}`, accessToken, undefined,
|
||||
input, { "If-Match": `"v${memory.version}"` }),
|
||||
deleteMemory: (memory: CuratedMemory, reason: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<CuratedMemory>>("DELETE", `/api/v1/memories/${memory.id}`, accessToken, undefined,
|
||||
{ reason }, { "If-Match": `"v${memory.version}"` }),
|
||||
privacyRequests: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: PrivacyRequest[] }>>("GET", `/api/v1/subjects/${subjectID}/privacy-requests`, accessToken, signal),
|
||||
privacyRequest: (requestID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<PrivacyRequest>>("GET", `/api/v1/privacy-requests/${requestID}`, accessToken, signal),
|
||||
createPrivacyAccessRequest: (subjectID: string, categories: string[], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<PrivacyRequest>>("POST", `/api/v1/subjects/${subjectID}/privacy-requests`, accessToken, undefined,
|
||||
{ request_type: "ACCESS", scope: { categories } }, { "Idempotency-Key": `privacy-access-${crypto.randomUUID()}` }),
|
||||
createPrivacyDeleteRequest: (subjectID: string, reason: string, confirmation: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<PrivacyRequest>>("POST", `/api/v1/subjects/${subjectID}/privacy-requests`, accessToken, undefined,
|
||||
{ request_type: "DELETE", scope: {}, reason, confirmation }, { "Idempotency-Key": `privacy-delete-${crypto.randomUUID()}` }),
|
||||
exportTasks: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: ExportTask[] }>>("GET", `/api/v1/subjects/${subjectID}/exports`, accessToken, signal),
|
||||
exportTask: (exportID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<ExportTask>>("GET", `/api/v1/exports/${exportID}`, accessToken, signal),
|
||||
createExportTask: (subjectID: string, categories: string[], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<ExportTask>>("POST", `/api/v1/subjects/${subjectID}/exports`, accessToken, undefined,
|
||||
{ scope: { categories } }, { "Idempotency-Key": `subject-export-${crypto.randomUUID()}` }),
|
||||
devices: (accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: ManagedDevice[] }>>("GET", "/api/v1/devices", accessToken, signal),
|
||||
devicePresence: (deviceID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<DevicePresence>>("GET", `/api/v1/devices/${deviceID}/presence`, accessToken, signal),
|
||||
deviceTwin: (deviceID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<DeviceTwin>>("GET", `/api/v1/devices/${deviceID}/twin`, accessToken, signal),
|
||||
members: (householdID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: HouseholdMember[] }>>("GET", `/api/v1/households/${householdID}/members`, accessToken, signal),
|
||||
invitations: (householdID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: MemberInvitation[] }>>("GET", `/api/v1/households/${householdID}/invitations`, accessToken, signal),
|
||||
createInvitation: (householdID: string, input: CreateMemberInvitationRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<MemberInvitation>>("POST", `/api/v1/households/${householdID}/invitations`, accessToken, undefined, input),
|
||||
revokeInvitation: (householdID: string, invitationID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<MemberInvitation>>("POST", `/api/v1/households/${householdID}/invitations/${invitationID}/revoke`, accessToken),
|
||||
previewInvitation: (token: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<MemberInvitationPreview>>("POST", "/api/v1/member-invitations/preview", accessToken, undefined, { token }),
|
||||
acceptInvitation: (token: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<HouseholdMember>>("POST", "/api/v1/member-invitations/accept", accessToken, undefined, { token }),
|
||||
updateMemberRole: (householdID: string, member: HouseholdMember, role: "admin" | "editor" | "viewer", accessToken: string) =>
|
||||
authenticatedJSON<Envelope<HouseholdMember>>("PATCH", `/api/v1/households/${householdID}/members/${member.id}`, accessToken, undefined,
|
||||
{ role }, { "If-Match": `"v${member.version}"` }),
|
||||
removeMember: (householdID: string, member: HouseholdMember, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<HouseholdMember>>("DELETE", `/api/v1/households/${householdID}/members/${member.id}`, accessToken, undefined,
|
||||
undefined, { "If-Match": `"v${member.version}"` }),
|
||||
transferOwnership: (household: Household, memberID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<Household>>("POST", `/api/v1/households/${household.id}/ownership`, accessToken, undefined,
|
||||
{ member_id: memberID }, { "If-Match": `"v${household.version}"` }),
|
||||
createSubject: (householdID: string, input: CreateSubjectRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<Subject>>("POST", `/api/v1/households/${householdID}/subjects`, accessToken, undefined, input),
|
||||
updateSubject: (subject: Subject, input: UpdateSubjectRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<Subject>>("PATCH", `/api/v1/subjects/${subject.id}`, accessToken, undefined, input,
|
||||
{ "If-Match": `"v${subject.version}"` }),
|
||||
createFact: (subjectID: string, input: CreateSubjectFactRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<SubjectFact>>("POST", `/api/v1/subjects/${subjectID}/facts`, accessToken, undefined, input),
|
||||
confirmFact: (subjectID: string, fact: SubjectFact, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<SubjectFact>>("PATCH", `/api/v1/subjects/${subjectID}/facts/${fact.id}`, accessToken, undefined,
|
||||
{ confirmation_status: "confirmed" }, { "If-Match": `"v${fact.version}"` }),
|
||||
grantConsent: (subjectID: string, input: GrantConsentRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<Consent>>("POST", `/api/v1/subjects/${subjectID}/consents`, accessToken, undefined, input),
|
||||
withdrawConsent: (subjectID: string, consentID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<Consent>>("POST", `/api/v1/subjects/${subjectID}/consents/${consentID}/withdraw`, accessToken),
|
||||
claimDevice: (input: DeviceClaimRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<components["schemas"]["DeviceClaimResult"]>>("POST", "/api/v1/device-claims", accessToken, undefined, input),
|
||||
updateDeviceTwin: (deviceID: string, revision: number, input: UpdateDeviceTwinRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<DeviceTwin>>("PATCH", `/api/v1/devices/${deviceID}/twin`, accessToken, undefined, input,
|
||||
{ "If-Match": `"v${revision}"` })
|
||||
,createDeviceCommand: (deviceID: string, type: "apply_config" | "reboot" | "diagnostics" | "firmware_update", payload: Record<string, unknown>, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<DeviceCommand>>("POST", `/api/v1/devices/${deviceID}/commands`, accessToken, undefined,
|
||||
{ type, payload, expires_in_sec: 300 }, { "Idempotency-Key": `device-command-${crypto.randomUUID()}` })
|
||||
,firmwareReleases: (householdID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: FirmwareRelease[] }>>("GET", `/api/v1/households/${householdID}/firmware-releases`, accessToken, signal)
|
||||
,firmwareReleaseImpact: (releaseID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<FirmwareReleaseImpact>>("GET", `/api/v1/firmware-releases/${releaseID}/impact`, accessToken, signal)
|
||||
,registerFirmwareSigningKey: (householdID: string, input: { key_id: string; public_key_base64: string }, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<{ key_id: string; status: string }>>("POST", `/api/v1/households/${householdID}/firmware-signing-keys`, accessToken, undefined, input)
|
||||
,firmwareSigningKeys: (householdID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: FirmwareSigningKey[] }>>("GET", `/api/v1/households/${householdID}/firmware-signing-keys`, accessToken, signal)
|
||||
,revokeFirmwareSigningKey: (householdID: string, keyID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FirmwareSigningKey>>("POST", `/api/v1/households/${householdID}/firmware-signing-keys/revoke`, accessToken, undefined, { key_id: keyID })
|
||||
,prepareFirmwareRelease: (householdID: string, input: components["schemas"]["PrepareFirmwareReleaseRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/households/${householdID}/firmware-releases`, accessToken, undefined, input)
|
||||
,finalizeFirmwareRelease: (releaseID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/firmware-releases/${releaseID}/finalize`, accessToken)
|
||||
,renewFirmwareUpload: (releaseID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/firmware-releases/${releaseID}/upload-session`, accessToken)
|
||||
,pauseFirmwareRelease: (releaseID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/firmware-releases/${releaseID}/pause`, accessToken)
|
||||
,resumeFirmwareRelease: (releaseID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/firmware-releases/${releaseID}/resume`, accessToken)
|
||||
,retireFirmwareRelease: (releaseID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FirmwareRelease>>("POST", `/api/v1/firmware-releases/${releaseID}/retire`, accessToken)
|
||||
,firmwareRollouts: (householdID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: FirmwareRollout[] }>>("GET", `/api/v1/households/${householdID}/firmware-rollouts`, accessToken, signal)
|
||||
,firmwareRollout: (rolloutID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<FirmwareRollout>>("GET", `/api/v1/firmware-rollouts/${rolloutID}`, accessToken, signal)
|
||||
,startFirmwareRollout: (householdID: string, input: components["schemas"]["StartFirmwareRolloutRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FirmwareRollout>>("POST", `/api/v1/households/${householdID}/firmware-rollouts`, accessToken, undefined, input)
|
||||
,pauseFirmwareRollout: (rolloutID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FirmwareRollout>>("POST", `/api/v1/firmware-rollouts/${rolloutID}/pause`, accessToken)
|
||||
,rollbackFirmwareRollout: (rolloutID: string, rollbackReleaseID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FirmwareRollout>>("POST", `/api/v1/firmware-rollouts/${rolloutID}/rollback`, accessToken, undefined,
|
||||
{ rollback_release_id: rollbackReleaseID })
|
||||
};
|
||||
|
||||
async function getJSONWithToken<T>(path: string, accessToken: string, signal?: AbortSignal): Promise<T> {
|
||||
return authenticatedJSON<T>("GET", path, accessToken, signal);
|
||||
}
|
||||
|
||||
async function authenticatedJSON<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
accessToken: string,
|
||||
signal?: AbortSignal,
|
||||
body?: unknown,
|
||||
extraHeaders: Record<string, string> = {}
|
||||
): Promise<T> {
|
||||
const response = await fetchWithTimeout(path, {
|
||||
method,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json; charset=utf-8" }),
|
||||
...extraHeaders
|
||||
}
|
||||
}, signal);
|
||||
if (!response.ok) {
|
||||
let code: string | undefined;
|
||||
let message = response.status === 401 ? "登录状态已经失效,请重新登录" : `请求失败(HTTP ${response.status})`;
|
||||
try {
|
||||
const body = (await response.json()) as { error?: { code?: string; message?: string } };
|
||||
code = body.error?.code;
|
||||
if (body.error?.message) message = body.error.message;
|
||||
} catch {
|
||||
// Keep the stable HTTP fallback when a proxy returned a non-JSON response.
|
||||
}
|
||||
if (response.status === 401) window.dispatchEvent(new Event(AUTHENTICATION_REQUIRED_EVENT));
|
||||
throw new APIRequestError(message, response.status, code);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit, callerSignal?: AbortSignal): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const onCallerAbort = () => controller.abort(callerSignal?.reason);
|
||||
if (callerSignal?.aborted) onCallerAbort();
|
||||
else callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
||||
const timeout = window.setTimeout(() => controller.abort(new DOMException("请求超时", "TimeoutError")), requestTimeoutMS);
|
||||
try {
|
||||
return await fetch(input, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (!callerSignal?.aborted && controller.signal.aborted) {
|
||||
throw new APIRequestError("请求超时,请检查网络后重试", 0, "REQUEST_TIMEOUT");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
callerSignal?.removeEventListener("abort", onCallerAbort);
|
||||
}
|
||||
}
|
||||
Vendored
+11007
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
import { UserManager, WebStorageStateStore, type User } from "oidc-client-ts";
|
||||
import { runtimeConfig } from "../runtimeConfig";
|
||||
|
||||
const origin = window.location.origin;
|
||||
|
||||
export const userManager = new UserManager({
|
||||
authority: runtimeConfig.oidcAuthority,
|
||||
client_id: runtimeConfig.oidcClientId,
|
||||
redirect_uri: `${origin}/auth/callback`,
|
||||
post_logout_redirect_uri: origin,
|
||||
response_type: "code",
|
||||
scope: "openid profile",
|
||||
monitorSession: false,
|
||||
automaticSilentRenew: true,
|
||||
accessTokenExpiringNotificationTimeInSeconds: 60,
|
||||
userStore: new WebStorageStateStore({ store: window.sessionStorage })
|
||||
});
|
||||
|
||||
let callbackPromise: Promise<User | undefined> | undefined;
|
||||
|
||||
type SigninState = {
|
||||
returnTo?: unknown;
|
||||
};
|
||||
|
||||
export function startSignin(): Promise<void> {
|
||||
return userManager.signinRedirect({
|
||||
state: { returnTo: currentRelativeURL() }
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeSigninIfNeeded(): Promise<void> {
|
||||
if (window.location.pathname !== "/auth/callback") return;
|
||||
callbackPromise ??= userManager.signinRedirectCallback();
|
||||
const user = await callbackPromise;
|
||||
const returnURL = safeReturnURL(user?.state);
|
||||
window.history.replaceState({}, document.title, returnURL);
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { returnURL } }));
|
||||
}
|
||||
|
||||
function currentRelativeURL(): string {
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
function safeReturnURL(state: unknown): string {
|
||||
const returnTo = (state as SigninState | undefined)?.returnTo;
|
||||
if (typeof returnTo !== "string" || !returnTo.startsWith("/") || returnTo.startsWith("//")) return "/";
|
||||
try {
|
||||
const target = new URL(returnTo, window.location.origin);
|
||||
if (target.origin !== window.location.origin || target.pathname === "/auth/callback") return "/";
|
||||
return `${target.pathname}${target.search}${target.hash}`;
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { User } from "oidc-client-ts";
|
||||
import { APIRequestError, AUTHENTICATION_REQUIRED_EVENT, platformApi, type CurrentUser } from "../api/client";
|
||||
import { completeSigninIfNeeded, startSignin, userManager } from "./oidc";
|
||||
|
||||
type AuthState = {
|
||||
loading: boolean;
|
||||
oidcUser?: User;
|
||||
currentUser?: CurrentUser;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function useAuth() {
|
||||
const [state, setState] = useState<AuthState>({ loading: true });
|
||||
|
||||
const loadUser = useCallback(async (user?: User | null) => {
|
||||
const currentOIDCUser = user ?? (await userManager.getUser());
|
||||
if (!currentOIDCUser || currentOIDCUser.expired) {
|
||||
if (currentOIDCUser?.expired) {
|
||||
await userManager.removeUser();
|
||||
setState({ loading: false, error: "登录状态已过期,请重新登录" });
|
||||
} else {
|
||||
setState({ loading: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await platformApi.currentUser(currentOIDCUser.access_token);
|
||||
setState({ loading: false, oidcUser: currentOIDCUser, currentUser: response.data });
|
||||
} catch (error) {
|
||||
if (error instanceof APIRequestError && error.status === 401) {
|
||||
await userManager.removeUser();
|
||||
setState({ loading: false, error: "登录状态已经失效,请重新登录" });
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
loading: false,
|
||||
oidcUser: currentOIDCUser,
|
||||
error: error instanceof Error ? error.message : "无法读取当前用户"
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let authenticationFailureInProgress = false;
|
||||
const initialize = async () => {
|
||||
try {
|
||||
await completeSigninIfNeeded();
|
||||
if (active) await loadUser();
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setState({ loading: false, error: error instanceof Error ? error.message : "登录回调处理失败" });
|
||||
}
|
||||
}
|
||||
};
|
||||
const onUserLoaded = (user: User) => { if (active) void loadUser(user); };
|
||||
const onUserUnloaded = () => { if (active) setState({ loading: false }); };
|
||||
const clearInvalidSession = (message: string) => {
|
||||
if (!active || authenticationFailureInProgress) return;
|
||||
authenticationFailureInProgress = true;
|
||||
void userManager.removeUser().finally(() => {
|
||||
authenticationFailureInProgress = false;
|
||||
if (active) setState({ loading: false, error: message });
|
||||
});
|
||||
};
|
||||
const onAccessTokenExpired = () => clearInvalidSession("登录状态已过期,请重新登录");
|
||||
const onSilentRenewError = () => clearInvalidSession("登录续期失败,请重新登录");
|
||||
const onAuthenticationRequired = () => clearInvalidSession("登录状态已经失效,请重新登录");
|
||||
userManager.events.addUserLoaded(onUserLoaded);
|
||||
userManager.events.addUserUnloaded(onUserUnloaded);
|
||||
userManager.events.addAccessTokenExpired(onAccessTokenExpired);
|
||||
userManager.events.addSilentRenewError(onSilentRenewError);
|
||||
window.addEventListener(AUTHENTICATION_REQUIRED_EVENT, onAuthenticationRequired);
|
||||
void initialize();
|
||||
return () => {
|
||||
active = false;
|
||||
userManager.events.removeUserLoaded(onUserLoaded);
|
||||
userManager.events.removeUserUnloaded(onUserUnloaded);
|
||||
userManager.events.removeAccessTokenExpired(onAccessTokenExpired);
|
||||
userManager.events.removeSilentRenewError(onSilentRenewError);
|
||||
window.removeEventListener(AUTHENTICATION_REQUIRED_EVENT, onAuthenticationRequired);
|
||||
};
|
||||
}, [loadUser]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
login: () => startSignin(),
|
||||
logout: () => userManager.signoutRedirect(),
|
||||
retry: () => loadUser(state.oidcUser)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
type StatusCardProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
tone: "good" | "warning" | "neutral";
|
||||
};
|
||||
|
||||
export function StatusCard({ label, value, detail, tone }: StatusCardProps) {
|
||||
return (
|
||||
<article className={`status-card status-card--${tone}`}>
|
||||
<div className="status-card__topline">
|
||||
<span>{label}</span>
|
||||
<span className="status-card__dot" aria-hidden="true" />
|
||||
</div>
|
||||
<strong>{value}</strong>
|
||||
<p>{detail}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { PlatformOverview } from "./pages/PlatformOverview";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<PlatformOverview />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,221 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import {
|
||||
APIRequestError, platformApi, type AlertDeliveryTest, type AlertIncident, type AlertRoutingPolicy,
|
||||
type AlertSilence, type CurrentUser
|
||||
} from "../api/client";
|
||||
|
||||
const categoryOptions = ["BUSINESS", "TECHNICAL", "SECURITY", "COST"] as const;
|
||||
const severityOptions = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const;
|
||||
type AlertTransitionAction = "acknowledge" | "mitigate" | "resolve";
|
||||
type AlertTransitionDraft = { action: AlertTransitionAction; item: AlertIncident; note: string; acknowledged: boolean };
|
||||
type AlertControlDraft =
|
||||
| { kind: "delivery_test"; item: AlertRoutingPolicy; reason: string; acknowledged: boolean }
|
||||
| { kind: "delivery_retry"; item: AlertDeliveryTest; reason: string; acknowledged: boolean }
|
||||
| { kind: "silence_revoke"; item: AlertSilence; reason: string; acknowledged: boolean };
|
||||
|
||||
export function OpsAlertWorkspace({ accessToken, accounts }: { accessToken: string; accounts: CurrentUser["accounts"] }) {
|
||||
const opsAccounts = accounts.filter((item) => item.platform_role !== "customer");
|
||||
const [tenantID, setTenantID] = useState(opsAccounts[0]?.tenant_id ?? "");
|
||||
const account = opsAccounts.find((item) => item.tenant_id === tenantID) ?? opsAccounts[0];
|
||||
const [routes, setRoutes] = useState<AlertRoutingPolicy[]>([]);
|
||||
const [deliveryTests, setDeliveryTests] = useState<AlertDeliveryTest[]>([]);
|
||||
const [alerts, setAlerts] = useState<AlertIncident[]>([]);
|
||||
const [silences, setSilences] = useState<AlertSilence[]>([]);
|
||||
const [selected, setSelected] = useState<AlertIncident>();
|
||||
const [status, setStatus] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [message, setMessage] = useState<string>();
|
||||
const [transitionDraft, setTransitionDraft] = useState<AlertTransitionDraft>();
|
||||
const [controlDraft, setControlDraft] = useState<AlertControlDraft>();
|
||||
|
||||
const [category, setCategory] = useState<typeof categoryOptions[number]>("TECHNICAL");
|
||||
const [severity, setSeverity] = useState<typeof severityOptions[number]>("HIGH");
|
||||
const [receiver, setReceiver] = useState("platform-oncall");
|
||||
const [responseMinutes, setResponseMinutes] = useState(15);
|
||||
const [escalationMinutes, setEscalationMinutes] = useState(30);
|
||||
const [escalationReceiver, setEscalationReceiver] = useState("incident-command");
|
||||
const [runbook, setRunbook] = useState("runbook://platform/alert-response");
|
||||
const [routeSummary, setRouteSummary] = useState("");
|
||||
|
||||
const [silenceRule, setSilenceRule] = useState("OUTBOX_DEAD_LETTER");
|
||||
const [silenceReason, setSilenceReason] = useState("");
|
||||
const [silenceHours, setSilenceHours] = useState(2);
|
||||
|
||||
const canOperate = Boolean(account && ["ops_operator", "ops_approver", "ops_admin"].includes(account.platform_role));
|
||||
const canRoute = Boolean(account && ["ops_approver", "ops_admin"].includes(account.platform_role));
|
||||
|
||||
async function load(signal?: AbortSignal) {
|
||||
if (!tenantID) return;
|
||||
setTransitionDraft(undefined);
|
||||
setControlDraft(undefined);
|
||||
setLoading(true); setError(undefined);
|
||||
try {
|
||||
const [routeResult, alertResult, silenceResult, deliveryTestResult] = await Promise.all([
|
||||
platformApi.opsAlertRoutingPolicies(tenantID, accessToken, signal),
|
||||
platformApi.opsAlerts(tenantID, status, accessToken, signal),
|
||||
platformApi.opsAlertSilences(tenantID, accessToken, signal),
|
||||
platformApi.opsAlertDeliveryTests(tenantID, accessToken, signal)
|
||||
]);
|
||||
setRoutes(routeResult.data.items);
|
||||
setAlerts(alertResult.data.items);
|
||||
setSilences(silenceResult.data.items);
|
||||
setDeliveryTests(deliveryTestResult.data.items);
|
||||
if (selected) {
|
||||
const current = alertResult.data.items.find((item) => item.id === selected.id);
|
||||
if (!current) setSelected(undefined);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(errorText(cause));
|
||||
} finally { if (!signal?.aborted) setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [tenantID, status, accessToken]);
|
||||
|
||||
async function selectAlert(item: AlertIncident) {
|
||||
setTransitionDraft(undefined);
|
||||
await run(async () => {
|
||||
const detail = await platformApi.opsAlert(tenantID, item.id, accessToken);
|
||||
setSelected(detail.data);
|
||||
}, false);
|
||||
}
|
||||
|
||||
function openTransition(action: AlertTransitionAction) {
|
||||
if (!selected) return;
|
||||
setControlDraft(undefined); setTransitionDraft({ action, item: selected, note: "", acknowledged: false }); setError(undefined); setMessage(undefined);
|
||||
}
|
||||
|
||||
async function transition(event: FormEvent) {
|
||||
event.preventDefault(); const draft = transitionDraft;
|
||||
if (!draft || !alertTransitionReady(draft)) return;
|
||||
await run(async () => {
|
||||
const result = await platformApi.transitionOpsAlert(tenantID, draft.item, draft.action, draft.note.trim(), accessToken);
|
||||
setSelected(result.data); setMessage(`告警已${alertTransitionPastLabel(draft.action)},处置动作和操作人已写入时间线。`); await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function createRoute(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await run(async () => {
|
||||
await platformApi.createOpsAlertRoutingPolicy(tenantID, {
|
||||
category, severity, receiver_group: receiver.trim(), response_minutes: responseMinutes,
|
||||
escalation_after_minutes: escalationMinutes, escalation_receiver_group: escalationReceiver.trim(),
|
||||
runbook_ref: runbook.trim(), change_summary: routeSummary.trim()
|
||||
}, accessToken);
|
||||
setRouteSummary(""); setMessage(`${categoryLabel(category)} · ${severityLabel(severity)} 路由新版本已激活。`); await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function createSilence(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const startsAt = new Date();
|
||||
const endsAt = new Date(startsAt.getTime() + silenceHours * 60 * 60 * 1000);
|
||||
await run(async () => {
|
||||
await platformApi.createOpsAlertSilence(tenantID, {
|
||||
rule_code: silenceRule.trim().toUpperCase(), matcher: {}, reason: silenceReason.trim(),
|
||||
starts_at: startsAt.toISOString(), ends_at: endsAt.toISOString()
|
||||
}, accessToken);
|
||||
setSilenceReason(""); setMessage(`规则 ${silenceRule.trim().toUpperCase()} 已静默 ${silenceHours} 小时;命中次数仍会保留。`); await load();
|
||||
});
|
||||
}
|
||||
|
||||
function openDeliveryTest(item: AlertRoutingPolicy) {
|
||||
setTransitionDraft(undefined); setControlDraft({ kind: "delivery_test", item, reason: "", acknowledged: false }); setError(undefined); setMessage(undefined);
|
||||
}
|
||||
|
||||
function openDeliveryRetry(item: AlertDeliveryTest) {
|
||||
setTransitionDraft(undefined); setControlDraft({ kind: "delivery_retry", item, reason: "", acknowledged: false }); setError(undefined); setMessage(undefined);
|
||||
}
|
||||
|
||||
function openSilenceRevoke(item: AlertSilence) {
|
||||
setTransitionDraft(undefined); setControlDraft({ kind: "silence_revoke", item, reason: "", acknowledged: false }); setError(undefined); setMessage(undefined);
|
||||
}
|
||||
|
||||
async function submitControl(event: FormEvent) {
|
||||
event.preventDefault(); const draft = controlDraft;
|
||||
if (!draft || !alertControlReady(draft)) return;
|
||||
await run(async () => {
|
||||
if (draft.kind === "delivery_test") {
|
||||
const result = await platformApi.createOpsAlertDeliveryTest(tenantID, draft.item.id, draft.reason.trim(), accessToken);
|
||||
setMessage(`联通测试 ${result.data.test_ref} 已进入可靠投递队列,请刷新查看Worker结果。`);
|
||||
} else if (draft.kind === "delivery_retry") {
|
||||
await platformApi.retryOpsDiagnosticJob(tenantID, draft.item.id, draft.reason.trim(), accessToken);
|
||||
setMessage(`联通测试 ${draft.item.test_ref} 已重新进入投递队列。`);
|
||||
} else {
|
||||
await platformApi.revokeOpsAlertSilence(tenantID, draft.item, draft.reason.trim(), accessToken);
|
||||
setMessage("静默已撤销;下一次规则扫描会把仍在发生的事件重新放回待处置队列。");
|
||||
}
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function run(action: () => Promise<void>, clearMessage = true) {
|
||||
setBusy(true); setError(undefined); if (clearMessage) setMessage(undefined);
|
||||
try { await action(); } catch (cause) { setError(errorText(cause)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
if (!opsAccounts.length) return null;
|
||||
const activeAlerts = alerts.filter((item) => !["RESOLVED", "SILENCED"].includes(item.status));
|
||||
const criticalAlerts = activeAlerts.filter((item) => item.severity === "CRITICAL").length;
|
||||
const escalatedAlerts = activeAlerts.filter((item) => item.escalated_at).length;
|
||||
const activeSilences = silences.filter((item) => !item.revoked_at && new Date(item.ends_at).getTime() > Date.now());
|
||||
|
||||
return <section className="ops-workspace ops-alerts" aria-label="告警处置工作台">
|
||||
<header className="ops-workspace__head"><div><p className="eyebrow">ALERT RESPONSE</p><h2>告警监测与处置闭环</h2><p>业务、技术、安全和费用信号统一归并;接收、升级、静默与人工处置均保留可审计时间线。</p></div><label><span>运营客户组织</span><select value={tenantID} onChange={(event) => { setTenantID(event.target.value); setSelected(undefined); }}>{opsAccounts.map((item) => <option key={item.user_id} value={item.tenant_id}>{item.tenant_name} · {roleLabel(item.platform_role)}</option>)}</select></label></header>
|
||||
{error ? <p className="workspace__inline-error" role="alert">{error}</p> : null}
|
||||
{message ? <p className="workspace__action-message">{message}</p> : null}
|
||||
|
||||
<div className="alert-summary-grid">
|
||||
<article><span>待处置</span><strong>{activeAlerts.length}</strong><small>已去除已关闭和静默事件</small></article>
|
||||
<article><span>严重事件</span><strong>{criticalAlerts}</strong><small>需要按响应时限优先接手</small></article>
|
||||
<article><span>已升级</span><strong>{escalatedAlerts}</strong><small>超过路由升级时限</small></article>
|
||||
<article><span>活动静默</span><strong>{activeSilences.length}</strong><small>命中仍计数,不发送处置通知</small></article>
|
||||
</div>
|
||||
|
||||
<div className="alert-queue-layout">
|
||||
<section className="alert-queue">
|
||||
<div className="alert-queue__toolbar"><strong>告警队列</strong><label><span>状态</span><select value={status} onChange={(event) => setStatus(event.target.value)}><option value="">全部状态</option><option value="OPEN">待确认</option><option value="ACKNOWLEDGED">已确认</option><option value="MITIGATED">已缓解</option><option value="SILENCED">静默中</option><option value="RESOLVED">已关闭</option></select></label></div>
|
||||
{loading ? <p>正在加载告警…</p> : alerts.length ? <ul>{alerts.map((item) => <li key={item.id} className={selected?.id === item.id ? "selected" : ""}><button type="button" onClick={() => void selectAlert(item)}><span className={`alert-severity alert-severity--${item.severity.toLowerCase()}`}>{severityLabel(item.severity)}</span><span><strong>{item.title}</strong><small>{categoryLabel(item.category)} · {statusLabel(item.status)} · 命中 {item.occurrence_count} 次</small><small>{new Date(item.last_detected_at).toLocaleString()} · {item.resource_type || "平台级"}</small></span></button></li>)}</ul> : <p>当前筛选范围没有告警。</p>}
|
||||
</section>
|
||||
<section className="alert-detail">
|
||||
{selected ? <><div className="alert-detail__title"><div><span>{selected.rule_code}</span><h3>{selected.title}</h3></div><b>{statusLabel(selected.status)}</b></div><p>{selected.summary}</p><dl><div><dt>接收组</dt><dd>{selected.receiver_group || "尚未配置路由"}</dd></div><div><dt>运行手册</dt><dd>{selected.runbook_ref || "尚未配置"}</dd></div><div><dt>关联资源</dt><dd>{selected.resource_type || "平台"} {selected.resource_id || ""}</dd></div><div><dt>请求关联号</dt><dd>{selected.request_id || "无"}</dd></div><div><dt>当前负责人</dt><dd>{selected.current_owner || "未接手"}</dd></div><div><dt>首次发现</dt><dd>{new Date(selected.first_detected_at).toLocaleString()}</dd></div></dl>
|
||||
{canOperate && selected.status !== "RESOLVED" && selected.status !== "SILENCED" ? <div className="alert-detail__actions">{selected.status === "OPEN" ? <button type="button" disabled={busy} onClick={() => openTransition("acknowledge")}>确认并接手</button> : null}{["OPEN", "ACKNOWLEDGED"].includes(selected.status) ? <button type="button" className="button-secondary" disabled={busy} onClick={() => openTransition("mitigate")}>记录缓解</button> : null}<button type="button" className="button-secondary" disabled={busy} onClick={() => openTransition("resolve")}>验证并关闭</button></div> : null}
|
||||
{transitionDraft?.item.id === selected.id ? <form className={`sensitive-access-request controlled-operation alert-transition-editor ${transitionDraft.action === "resolve" ? "controlled-operation--danger" : ""}`} aria-label="告警处置动作" onSubmit={(event) => void transition(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">ALERT RESPONSE ACTION</p><h3>{alertTransitionTitle(transitionDraft.action)}</h3></div><span className={`status ${transitionDraft.action === "resolve" ? "danger" : "warning"}`}>{transitionDraft.action === "resolve" ? "闭环前复核" : "写入时间线"}</span></div><p>告警 <strong>{selected.id}</strong> · {selected.rule_code} · {categoryLabel(selected.category)} / {severityLabel(selected.severity)} · 当前{statusLabel(selected.status)} · 命中 {selected.occurrence_count} 次 · 行版本 v{selected.row_version}。提交后动作、说明、操作人和时间将写入审计时间线。</p><label><span>{alertTransitionNoteLabel(transitionDraft.action)}</span><textarea autoFocus required maxLength={1000} value={transitionDraft.note} onChange={(event) => setTransitionDraft({ ...transitionDraft, note: event.target.value })} placeholder={alertTransitionPlaceholder(transitionDraft.action)} /></label>{transitionDraft.action === "resolve" ? <label className="controlled-operation__confirmation"><input type="checkbox" checked={transitionDraft.acknowledged} onChange={(event) => setTransitionDraft({ ...transitionDraft, acknowledged: event.target.checked })} />我确认缓解措施已经验证,相关监测指标恢复正常,关联影响已复核;关闭后如信号再次出现将作为新的活动告警处理</label> : null}<div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={() => setTransitionDraft(undefined)}>取消</button><button type="submit" className={transitionDraft.action === "resolve" ? "button-danger" : ""} disabled={busy || !alertTransitionReady(transitionDraft)}>{busy ? "正在处理…" : alertTransitionSubmitLabel(transitionDraft.action)}</button></div></form> : null}
|
||||
<div className="alert-timeline"><strong>处置时间线</strong>{selected.actions.length ? <ol>{selected.actions.map((action) => <li key={action.id}><span>{actionLabel(action.action_type)}</span><p>{action.note}</p><small>{new Date(action.created_at).toLocaleString()} · {action.actor_type === "system" ? "系统" : action.actor_user || "运营人员"}</small></li>)}</ol> : <p>读取详情后显示完整动作记录。</p>}</div></> : <div className="alert-detail__empty"><strong>选择一条告警查看处置上下文</strong><p>详情包含接收组、运行手册、关联资源、请求关联号和完整动作时间线。</p></div>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="alert-config-grid">
|
||||
{canRoute ? <form className="alert-config-form" onSubmit={(event) => void createRoute(event)}><div><strong>激活告警路由新版本</strong><span>同分类同级别的旧版本自动退役。</span></div><label><span>分类</span><select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>{categoryOptions.map((item) => <option key={item} value={item}>{categoryLabel(item)}</option>)}</select></label><label><span>级别</span><select value={severity} onChange={(event) => setSeverity(event.target.value as typeof severity)}>{severityOptions.map((item) => <option key={item} value={item}>{severityLabel(item)}</option>)}</select></label><label><span>首接组</span><input value={receiver} onChange={(event) => setReceiver(event.target.value)} required /></label><label><span>响应时限(分钟)</span><input type="number" min={1} max={10080} value={responseMinutes} onChange={(event) => setResponseMinutes(Number(event.target.value))} required /></label><label><span>升级接收组</span><input value={escalationReceiver} onChange={(event) => setEscalationReceiver(event.target.value)} required /></label><label><span>升级时限(分钟)</span><input type="number" min={responseMinutes} max={10080} value={escalationMinutes} onChange={(event) => setEscalationMinutes(Number(event.target.value))} required /></label><label className="alert-config-form__wide"><span>运行手册引用</span><input value={runbook} onChange={(event) => setRunbook(event.target.value)} required /></label><label className="alert-config-form__wide"><span>变更说明</span><input value={routeSummary} maxLength={500} onChange={(event) => setRouteSummary(event.target.value)} required /></label><button type="submit" disabled={busy}>激活路由版本</button></form> : <p className="workspace__readonly-note">当前角色可查看告警路由,但不能发布新版本。</p>}
|
||||
{canOperate ? <form className="alert-config-form alert-config-form--silence" onSubmit={(event) => void createSilence(event)}><div><strong>创建临时静默</strong><span>静默窗口最长30天,原因不能为空。</span></div><label className="alert-config-form__wide"><span>规则代码</span><input value={silenceRule} onChange={(event) => setSilenceRule(event.target.value.toUpperCase())} pattern="[A-Z][A-Z0-9_]{2,99}" required /></label><label><span>时长(小时)</span><input type="number" min={1} max={720} value={silenceHours} onChange={(event) => setSilenceHours(Number(event.target.value))} required /></label><label className="alert-config-form__wide"><span>静默原因</span><input value={silenceReason} maxLength={500} onChange={(event) => setSilenceReason(event.target.value)} required /></label><button type="submit" disabled={busy}>创建静默窗口</button></form> : null}
|
||||
</div>
|
||||
|
||||
<div className="alert-ledger-grid"><section><div><strong>活动路由</strong><span>{routes.filter((item) => item.status === "ACTIVE").length} 条</span></div><ul>{routes.filter((item) => item.status === "ACTIVE").map((item) => <li key={item.id}><span><strong>{categoryLabel(item.category)} · {severityLabel(item.severity)} · v{item.version_no}</strong><small>{item.receiver_group} / {item.response_minutes}分钟 → {item.escalation_receiver_group} / {item.escalation_after_minutes}分钟</small></span><b>{item.runbook_ref}</b>{canOperate ? <button type="button" className="button-small" disabled={busy} onClick={() => openDeliveryTest(item)}>测试通知</button> : null}</li>)}</ul></section><section><div><strong>静默记录</strong><span>{silences.length} 条</span></div><ul>{silences.map((item) => { const active = !item.revoked_at && new Date(item.ends_at).getTime() > Date.now(); return <li key={item.id}><span><strong>{item.rule_code || item.category || item.resource_id} · {active ? "生效中" : "已结束"}</strong><small>{item.reason} · 至 {new Date(item.ends_at).toLocaleString()} · 行版本 v{item.row_version}</small></span>{active && canOperate ? <button type="button" className="button-small" disabled={busy} onClick={() => openSilenceRevoke(item)}>提前撤销</button> : null}</li>; })}</ul></section><section><div><strong>接收器联通测试</strong><span>最近 {deliveryTests.length} 次</span></div><ul>{deliveryTests.length ? deliveryTests.map((item) => <li key={item.id}><span><strong>{categoryLabel(item.category)} · {severityLabel(item.severity)} · {deliveryStatusLabel(item.status)}</strong><small>{item.receiver_group} · 尝试 {item.attempt_count} 次{item.manual_retry_count ? ` · 人工重试 ${item.manual_retry_count} 次` : ""}</small><small>{item.test_ref} · {new Date(item.created_at).toLocaleString()}</small>{item.last_error_code ? <small>错误码:{item.last_error_code}</small> : null}</span>{item.status === "FAILED" && canOperate ? <button type="button" className="button-small" disabled={busy} onClick={() => openDeliveryRetry(item)}>受控重试</button> : null}</li>) : <li><span><strong>尚未发起联通测试</strong><small>从活动路由右侧“测试通知”开始。</small></span></li>}</ul></section></div>
|
||||
|
||||
{controlDraft ? <form className={`sensitive-access-request controlled-operation alert-control-editor ${controlDraft.kind === "delivery_test" ? "" : "controlled-operation--danger"}`} aria-label="告警受控操作" onSubmit={(event) => void submitControl(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">CONTROLLED ALERT OPERATION</p><h3>{alertControlTitle(controlDraft.kind)}</h3></div><span className={`status ${controlDraft.kind === "delivery_test" ? "warning" : "danger"}`}>{controlDraft.kind === "delivery_test" ? "测试通知" : "影响告警队列"}</span></div>{controlDraft.kind === "delivery_test" ? <p>活动路由 <strong>{controlDraft.item.id}</strong> · {categoryLabel(controlDraft.item.category)} / {severityLabel(controlDraft.item.severity)} · v{controlDraft.item.version_no} · {controlDraft.item.receiver_group} → {controlDraft.item.escalation_receiver_group} · {controlDraft.item.runbook_ref} · 行版本 v{controlDraft.item.row_version}。测试复用正式Webhook签名和Worker链路,但不会创建正式告警。</p> : controlDraft.kind === "delivery_retry" ? <p>失败任务 <strong>{controlDraft.item.id}</strong> · 测试关联号 {controlDraft.item.test_ref} · {categoryLabel(controlDraft.item.category)} / {severityLabel(controlDraft.item.severity)} · {controlDraft.item.receiver_group} · 已尝试 {controlDraft.item.attempt_count} 次 · 人工重试 {controlDraft.item.manual_retry_count} 次 · 错误码 {controlDraft.item.last_error_code || "未记录"}。重试会重新进入可靠投递队列。</p> : <p>静默 <strong>{controlDraft.item.id}</strong> · {controlDraft.item.rule_code || controlDraft.item.category || controlDraft.item.resource_id} · {controlDraft.item.reason} · {new Date(controlDraft.item.starts_at).toLocaleString()} 至 {new Date(controlDraft.item.ends_at).toLocaleString()} · 行版本 v{controlDraft.item.row_version}。撤销后仍在发生的信号会在下一次扫描重新进入待处置队列。</p>}<label><span>{alertControlReasonLabel(controlDraft.kind)}</span><textarea autoFocus required maxLength={500} value={controlDraft.reason} onChange={(event) => setControlDraft({ ...controlDraft, reason: event.target.value })} placeholder={alertControlPlaceholder(controlDraft.kind)} /></label><label className="controlled-operation__confirmation"><input type="checkbox" checked={controlDraft.acknowledged} onChange={(event) => setControlDraft({ ...controlDraft, acknowledged: event.target.checked })} />{alertControlAcknowledgement(controlDraft.kind)}</label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={() => setControlDraft(undefined)}>取消</button><button type="submit" className={controlDraft.kind === "delivery_test" ? "" : "button-danger"} disabled={busy || !alertControlReady(controlDraft)}>{busy ? "正在处理…" : alertControlSubmitLabel(controlDraft.kind)}</button></div></form> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function categoryLabel(value: string) { return ({ BUSINESS: "业务", TECHNICAL: "技术", SECURITY: "安全", COST: "费用" } as Record<string, string>)[value] ?? value; }
|
||||
function severityLabel(value: string) { return ({ CRITICAL: "严重", HIGH: "高", MEDIUM: "中", LOW: "低" } as Record<string, string>)[value] ?? value; }
|
||||
function statusLabel(value: string) { return ({ OPEN: "待确认", ACKNOWLEDGED: "已确认", MITIGATED: "已缓解", RESOLVED: "已关闭", SILENCED: "静默中" } as Record<string, string>)[value] ?? value; }
|
||||
function actionLabel(value: string) { return ({ DETECTED: "首次发现", ACKNOWLEDGED: "确认接手", ASSIGNED: "指派", SILENCED: "进入静默", MITIGATED: "完成缓解", RESOLVED: "验证关闭", REOPENED: "重新打开", ESCALATED: "超时升级", COMMENTED: "补充说明" } as Record<string, string>)[value] ?? value; }
|
||||
function deliveryStatusLabel(value: string) { return ({ PENDING: "等待投递", PROCESSING: "正在投递", DELIVERED: "投递成功", FAILED: "投递失败" } as Record<string, string>)[value] ?? value; }
|
||||
function roleLabel(value: string) { return ({ ops_viewer: "运营查看", ops_operator: "运营操作", ops_approver: "运营审批", ops_admin: "运营管理员" } as Record<string, string>)[value] ?? value; }
|
||||
function errorText(cause: unknown) { if (cause instanceof APIRequestError) return cause.code ? `${cause.message}(${cause.code})` : cause.message; return cause instanceof Error ? cause.message : "操作失败"; }
|
||||
function alertTransitionReady(draft: AlertTransitionDraft) { return Boolean(draft.note.trim() && (draft.action !== "resolve" || draft.acknowledged)); }
|
||||
function alertTransitionTitle(action: AlertTransitionAction) { return ({ acknowledge: "确认并接手告警", mitigate: "记录告警缓解", resolve: "验证并关闭告警" } as const)[action]; }
|
||||
function alertTransitionSubmitLabel(action: AlertTransitionAction) { return ({ acknowledge: "确认接手", mitigate: "确认记录缓解", resolve: "确认关闭告警" } as const)[action]; }
|
||||
function alertTransitionPastLabel(action: AlertTransitionAction) { return ({ acknowledge: "确认并接手", mitigate: "记录缓解", resolve: "验证关闭" } as const)[action]; }
|
||||
function alertTransitionNoteLabel(action: AlertTransitionAction) { return ({ acknowledge: "接手说明", mitigate: "缓解说明", resolve: "关闭验证说明" } as const)[action]; }
|
||||
function alertTransitionPlaceholder(action: AlertTransitionAction) { return ({ acknowledge: "填写当前判断、接手范围和下一处置步骤", mitigate: "填写已执行措施、影响变化和后续观察项", resolve: "填写验证结果、恢复指标和遗留风险复核" } as const)[action]; }
|
||||
function alertControlReady(draft: AlertControlDraft) { return Boolean(draft.reason.trim() && draft.acknowledged); }
|
||||
function alertControlTitle(kind: AlertControlDraft["kind"]) { return ({ delivery_test: "发起接收器联通测试", delivery_retry: "受控重试失败测试", silence_revoke: "提前撤销告警静默" } as const)[kind]; }
|
||||
function alertControlSubmitLabel(kind: AlertControlDraft["kind"]) { return ({ delivery_test: "确认发送测试通知", delivery_retry: "确认受控重试", silence_revoke: "确认撤销静默" } as const)[kind]; }
|
||||
function alertControlReasonLabel(kind: AlertControlDraft["kind"]) { return ({ delivery_test: "测试原因", delivery_retry: "重试原因和修复依据", silence_revoke: "提前撤销原因" } as const)[kind]; }
|
||||
function alertControlPlaceholder(kind: AlertControlDraft["kind"]) { return ({ delivery_test: "说明测试目的、接收组和值班确认范围", delivery_retry: "说明接收器配置或网络问题的修复证据", silence_revoke: "说明维护已结束或静默不再适用的依据" } as const)[kind]; }
|
||||
function alertControlAcknowledgement(kind: AlertControlDraft["kind"]) { return ({ delivery_test: "我确认接收组知晓本次测试,测试不会创建正式告警,填写原因只进入运营审计且不会发送给外部接收器", delivery_retry: "我确认接收器配置或网络问题已经处理,重新投递不会被误认为新的正式故障,且将增加人工重试次数", silence_revoke: "我确认静默条件已经解除,并理解仍在发生的信号会在下一次扫描重新进入待处置队列" } as const)[kind]; }
|
||||
@@ -0,0 +1,207 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import {
|
||||
APIRequestError, platformApi, type BillingPriceBook, type CostSummary,
|
||||
type CurrentUser, type QuotaPolicy
|
||||
} from "../api/client";
|
||||
|
||||
const categories = ["RTC", "END_TO_END_VOICE", "ASR", "LLM", "TTS", "STORAGE", "POST_PROCESSING"] as const;
|
||||
|
||||
type RateDraft = {
|
||||
usage_category: typeof categories[number];
|
||||
metric_code: string;
|
||||
unit: string;
|
||||
unit_price_micros: number;
|
||||
};
|
||||
type PricePublishDraft = { item: BillingPriceBook; reason: string; acknowledged: boolean };
|
||||
|
||||
const initialRates: RateDraft[] = [
|
||||
{ usage_category: "RTC", metric_code: "connected_minute", unit: "minute", unit_price_micros: 10000 },
|
||||
{ usage_category: "END_TO_END_VOICE", metric_code: "agent_minute", unit: "minute", unit_price_micros: 20000 },
|
||||
{ usage_category: "ASR", metric_code: "audio_minute", unit: "minute", unit_price_micros: 30000 },
|
||||
{ usage_category: "LLM", metric_code: "token", unit: "token", unit_price_micros: 2 },
|
||||
{ usage_category: "TTS", metric_code: "character", unit: "character", unit_price_micros: 1 },
|
||||
{ usage_category: "STORAGE", metric_code: "gigabyte_day", unit: "gb_day", unit_price_micros: 1000 },
|
||||
{ usage_category: "POST_PROCESSING", metric_code: "job", unit: "job", unit_price_micros: 50000 }
|
||||
];
|
||||
|
||||
export function OpsBillingWorkspace({ accessToken, accounts }: { accessToken: string; accounts: CurrentUser["accounts"] }) {
|
||||
const opsAccounts = accounts.filter((item) => item.platform_role !== "customer");
|
||||
const [tenantID, setTenantID] = useState(opsAccounts[0]?.tenant_id ?? "");
|
||||
const account = opsAccounts.find((item) => item.tenant_id === tenantID) ?? opsAccounts[0];
|
||||
const [priceBooks, setPriceBooks] = useState<BillingPriceBook[]>([]);
|
||||
const [policies, setPolicies] = useState<QuotaPolicy[]>([]);
|
||||
const [cost, setCost] = useState<CostSummary>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [message, setMessage] = useState<string>();
|
||||
const [publishDraft, setPublishDraft] = useState<PricePublishDraft>();
|
||||
const [provider, setProvider] = useState("mock");
|
||||
const [currency, setCurrency] = useState("CNY");
|
||||
const [sourceRef, setSourceRef] = useState("contract://local-dev/mock-v1");
|
||||
const [priceSummary, setPriceSummary] = useState("");
|
||||
const [effectiveFrom, setEffectiveFrom] = useState(localDateTime(new Date(Date.now() - 60_000)));
|
||||
const [rates, setRates] = useState<RateDraft[]>(initialRates);
|
||||
const [scopeType, setScopeType] = useState<"TENANT" | "DEVICE">("TENANT");
|
||||
const [scopeID, setScopeID] = useState("");
|
||||
const [timezone, setTimezone] = useState("Asia/Shanghai");
|
||||
const [dailyYuan, setDailyYuan] = useState(100);
|
||||
const [monthlyYuan, setMonthlyYuan] = useState(2000);
|
||||
const [concurrent, setConcurrent] = useState(10);
|
||||
const [durationMinutes, setDurationMinutes] = useState(60);
|
||||
const [action, setAction] = useState<"STOP_IMMEDIATELY" | "COMPLETE_CURRENT_TURN">("COMPLETE_CURRENT_TURN");
|
||||
const [quotaSummary, setQuotaSummary] = useState("");
|
||||
|
||||
const canDraft = account && ["ops_operator", "ops_approver", "ops_admin"].includes(account.platform_role);
|
||||
const canApprove = account && ["ops_approver", "ops_admin"].includes(account.platform_role);
|
||||
|
||||
async function load(signal?: AbortSignal) {
|
||||
if (!tenantID) return;
|
||||
setPublishDraft(undefined);
|
||||
setLoading(true); setError(undefined);
|
||||
const now = new Date();
|
||||
const from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
try {
|
||||
const [books, quota, summary] = await Promise.all([
|
||||
platformApi.opsPriceBooks(tenantID, accessToken, signal),
|
||||
platformApi.opsQuotaPolicies(tenantID, accessToken, signal),
|
||||
platformApi.opsCostSummary(tenantID, from.toISOString(), now.toISOString(), accessToken, signal)
|
||||
]);
|
||||
setPriceBooks(books.data.items); setPolicies(quota.data.items); setCost(summary.data);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(errorText(cause));
|
||||
} finally { if (!signal?.aborted) setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [tenantID, accessToken]);
|
||||
|
||||
function changeRate(index: number, field: keyof RateDraft, value: string) {
|
||||
setRates((current) => current.map((rate, position) => position === index
|
||||
? { ...rate, [field]: field === "unit_price_micros" ? Number(value) : value }
|
||||
: rate));
|
||||
}
|
||||
|
||||
async function createPriceBook(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await run(async () => {
|
||||
await platformApi.createOpsPriceBook(tenantID, {
|
||||
provider: provider.trim().toLowerCase(), currency: currency.trim().toUpperCase(), source_ref: sourceRef.trim(),
|
||||
change_summary: priceSummary.trim(), effective_from: new Date(effectiveFrom).toISOString(), rates
|
||||
}, accessToken);
|
||||
setPriceSummary(""); setMessage("价格草稿已创建。需由另一名审批人员发布后,新的供应商用量才会按该版本计费。"); await load();
|
||||
});
|
||||
}
|
||||
|
||||
function openPublish(item: BillingPriceBook) {
|
||||
setPublishDraft({ item, reason: "", acknowledged: false }); setError(undefined); setMessage(undefined);
|
||||
}
|
||||
|
||||
async function publish(event: FormEvent) {
|
||||
event.preventDefault(); const draft = publishDraft;
|
||||
if (!draft || !pricePublishReady(draft)) return;
|
||||
await run(async () => {
|
||||
await platformApi.publishOpsPriceBook(tenantID, draft.item, draft.reason.trim(), accessToken);
|
||||
setMessage(`价格版本 ${draft.item.provider} v${draft.item.version_no} 已发布;既有费用行仍保留原冻结单价。`); await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function createQuota(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await run(async () => {
|
||||
await platformApi.createOpsQuotaPolicy(tenantID, {
|
||||
scope_type: scopeType, ...(scopeType === "DEVICE" ? { scope_id: scopeID.trim() } : {}),
|
||||
billing_timezone: timezone.trim(), currency: currency.trim().toUpperCase(),
|
||||
daily_limit_micros: Math.round(dailyYuan * 1_000_000), monthly_limit_micros: Math.round(monthlyYuan * 1_000_000),
|
||||
max_concurrent_calls: concurrent, max_call_duration_sec: durationMinutes * 60,
|
||||
in_progress_action: action, change_summary: quotaSummary.trim()
|
||||
}, accessToken);
|
||||
setQuotaSummary(""); setMessage("新额度策略已激活。后续新通话会冻结当时有效的额度边界与超时处置策略。"); await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function run(actionToRun: () => Promise<void>) {
|
||||
setBusy(true); setError(undefined); setMessage(undefined);
|
||||
try { await actionToRun(); } catch (cause) { setError(errorText(cause)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
if (!opsAccounts.length) return null;
|
||||
const activePolicyCount = policies.filter((item) => item.status === "ACTIVE").length;
|
||||
const publishedBookCount = priceBooks.filter((item) => item.status === "PUBLISHED").length;
|
||||
|
||||
return <section className="ops-workspace ops-billing" aria-label="用量费用与额度工作台">
|
||||
<header className="ops-workspace__head"><div><p className="eyebrow">USAGE & COST CONTROL</p><h2>用量、费用与额度</h2><p>供应商最终用量去重入账,价格版本随费用行冻结;硬额度在创建通话前统一校验。</p></div><label><span>运营客户组织</span><select value={tenantID} onChange={(event) => setTenantID(event.target.value)}>{opsAccounts.map((item) => <option key={item.user_id} value={item.tenant_id}>{item.tenant_name} · {roleLabel(item.platform_role)}</option>)}</select></label></header>
|
||||
{error ? <p className="workspace__inline-error" role="alert">{error}</p> : null}
|
||||
{message ? <p className="workspace__action-message">{message}</p> : null}
|
||||
|
||||
<div className="billing-summary-grid">
|
||||
<article><span>本月已冻结费用</span><strong>{formatMoney(cost?.cost_micros ?? 0, cost?.currency || currency)}</strong><small>{cost?.lines.length ?? 0} 个聚合计量项</small></article>
|
||||
<article><span>已发布价格版本</span><strong>{publishedBookCount}</strong><small>按客户组织与供应商独立版本化</small></article>
|
||||
<article><span>当前活动额度策略</span><strong>{activePolicyCount}</strong><small>客户组织与设备范围取更严格边界</small></article>
|
||||
</div>
|
||||
|
||||
{canDraft ? <form className="billing-form" onSubmit={(event) => void createPriceBook(event)}>
|
||||
<div className="billing-form__intro"><strong>新建供应商价格草稿</strong><span>百万分之一货币单位精确计价;发布后才参与最终用量核价。</span></div>
|
||||
<label><span>供应商</span><input value={provider} onChange={(event) => setProvider(event.target.value)} required /></label>
|
||||
<label><span>币种</span><input value={currency} maxLength={3} onChange={(event) => setCurrency(event.target.value.toUpperCase())} required /></label>
|
||||
<label><span>生效时间</span><input type="datetime-local" value={effectiveFrom} onChange={(event) => setEffectiveFrom(event.target.value)} required /></label>
|
||||
<label className="billing-form__wide"><span>报价/合同引用</span><input value={sourceRef} onChange={(event) => setSourceRef(event.target.value)} required /></label>
|
||||
<div className="billing-rate-table billing-form__wide"><div><span>类别</span><span>指标</span><span>单位</span><span>单价(微单位)</span></div>{rates.map((rate, index) => <label key={rate.usage_category}><strong>{categoryLabel(rate.usage_category)}</strong><input aria-label={`${rate.usage_category}指标`} value={rate.metric_code} onChange={(event) => changeRate(index, "metric_code", event.target.value)} required /><input aria-label={`${rate.usage_category}单位`} value={rate.unit} onChange={(event) => changeRate(index, "unit", event.target.value)} required /><input aria-label={`${rate.usage_category}单价`} type="number" min={0} value={rate.unit_price_micros} onChange={(event) => changeRate(index, "unit_price_micros", event.target.value)} required /></label>)}</div>
|
||||
<label className="billing-form__wide"><span>变更说明</span><input value={priceSummary} maxLength={500} onChange={(event) => setPriceSummary(event.target.value)} required /></label>
|
||||
<button type="submit" disabled={busy}>创建价格草稿</button>
|
||||
</form> : null}
|
||||
|
||||
<div className="billing-ledger-grid">
|
||||
<section className="billing-ledger"><div><strong>价格版本</strong><span>{priceBooks.length} 条</span></div>{loading ? <p>正在加载…</p> : priceBooks.length ? <ul>{priceBooks.map((item) => <li key={item.id}><span><strong>{item.provider} v{item.version_no} · {statusLabel(item.status)}</strong><small>{item.currency} · {item.rates.length}项 · {new Date(item.effective_from).toLocaleString()} · 行版本 v{item.row_version}</small></span>{item.status === "DRAFT" && canApprove ? <button type="button" className="button-small" disabled={busy} onClick={() => openPublish(item)}>审批发布</button> : null}</li>)}</ul> : <p>尚无价格版本。</p>}</section>
|
||||
<section className="billing-ledger"><div><strong>本月费用构成</strong><span>{cost?.lines.length ?? 0} 项</span></div>{cost?.lines.length ? <ul>{cost.lines.map((line) => <li key={`${line.usage_category}-${line.metric_code}-${line.unit}-${line.currency}`}><span><strong>{categoryLabel(line.usage_category)} · {line.metric_code}</strong><small>{line.quantity} {line.unit}</small></span><b>{formatMoney(line.cost_micros, line.currency)}</b></li>)}</ul> : <p>当前周期尚无已核价用量。</p>}</section>
|
||||
</div>
|
||||
|
||||
{publishDraft ? <form className="sensitive-access-request controlled-operation ops-billing-publish" aria-label="价格版本审批发布" onSubmit={(event) => void publish(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">PRICE VERSION APPROVAL</p><h3>审批并发布价格版本</h3></div><span className="status warning">双人审批</span></div><p>价格版本 <strong>{publishDraft.item.id}</strong> · {publishDraft.item.provider} v{publishDraft.item.version_no} · {publishDraft.item.currency} · {publishDraft.item.rates.length}项费率 · 生效时间 {new Date(publishDraft.item.effective_from).toLocaleString()} · 创建人 {publishDraft.item.created_by} · 行版本 v{publishDraft.item.row_version}。发布只用于后续最终用量核价,既有费用行继续保留其冻结价格。</p><label><span>发布依据</span><textarea autoFocus required maxLength={500} value={publishDraft.reason} onChange={(event) => setPublishDraft({ ...publishDraft, reason: event.target.value })} placeholder="填写报价/合同复核结论、费率与生效时间核对依据" /></label><label className="controlled-operation__confirmation"><input type="checkbox" checked={publishDraft.acknowledged} onChange={(event) => setPublishDraft({ ...publishDraft, acknowledged: event.target.checked })} />我确认本人不是该版本创建人,报价/合同引用、币种、全部费率和生效时间均已复核,并理解同供应商旧发布版本将退役但历史费用不会重算</label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={() => setPublishDraft(undefined)}>取消</button><button type="submit" disabled={busy || !pricePublishReady(publishDraft)}>{busy ? "正在发布…" : "确认审批并发布"}</button></div></form> : null}
|
||||
|
||||
{canApprove ? <form className="billing-form billing-form--quota" onSubmit={(event) => void createQuota(event)}>
|
||||
<div className="billing-form__intro"><strong>创建并激活额度策略版本</strong><span>达到日/月硬额度后,新通话返回稳定的 429 业务错误;通话中时长处置在会话创建时冻结。</span></div>
|
||||
<label><span>范围</span><select value={scopeType} onChange={(event) => { setScopeType(event.target.value as "TENANT" | "DEVICE"); setScopeID(""); }}><option value="TENANT">整个客户组织</option><option value="DEVICE">单台设备</option></select></label>
|
||||
{scopeType === "DEVICE" ? <label><span>设备公开编号</span><input value={scopeID} onChange={(event) => setScopeID(event.target.value)} required /></label> : null}
|
||||
<label><span>账期时区</span><input value={timezone} onChange={(event) => setTimezone(event.target.value)} required /></label>
|
||||
<label><span>日额度(元)</span><input type="number" min={0.000001} step={0.01} value={dailyYuan} onChange={(event) => setDailyYuan(Number(event.target.value))} required /></label>
|
||||
<label><span>月额度(元)</span><input type="number" min={0.000001} step={0.01} value={monthlyYuan} onChange={(event) => setMonthlyYuan(Number(event.target.value))} required /></label>
|
||||
<label><span>最大并发通话</span><input type="number" min={1} max={100000} value={concurrent} onChange={(event) => setConcurrent(Number(event.target.value))} required /></label>
|
||||
<label><span>单次最长时长(分钟)</span><input type="number" min={1} max={1440} value={durationMinutes} onChange={(event) => setDurationMinutes(Number(event.target.value))} required /></label>
|
||||
<label><span>通话中达到时长上限</span><select value={action} onChange={(event) => setAction(event.target.value as typeof action)}><option value="COMPLETE_CURRENT_TURN">完成当前轮后停止</option><option value="STOP_IMMEDIATELY">立即停止</option></select></label>
|
||||
<label className="billing-form__wide"><span>策略变更说明</span><input value={quotaSummary} maxLength={500} onChange={(event) => setQuotaSummary(event.target.value)} required /></label>
|
||||
<button type="submit" disabled={busy}>激活新策略版本</button>
|
||||
</form> : <p className="workspace__readonly-note">当前运营角色可查看费用,但不能激活额度策略。</p>}
|
||||
|
||||
<section className="billing-policy-list"><div><strong>额度策略历史</strong><span>{policies.length} 条</span></div>{policies.length ? <ul>{policies.map((item) => <li key={item.id}><span><strong>{item.scope_type === "TENANT" ? "客户组织" : "设备"} v{item.version_no} · {item.status === "ACTIVE" ? "活动" : "已退役"}</strong><small>{item.scope_id || "全组织"} · {item.billing_timezone} · {item.in_progress_action === "STOP_IMMEDIATELY" ? "到时立即停止" : "完成当前轮后停止"}</small></span><span>日 {formatMoney(item.daily_limit_micros, item.currency)} · 月 {formatMoney(item.monthly_limit_micros, item.currency)} · 并发≤{item.max_concurrent_calls} · {Math.round(item.max_call_duration_sec / 60)}分钟</span></li>)}</ul> : <p>尚无额度策略。</p>}</section>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function localDateTime(value: Date) {
|
||||
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function formatMoney(micros: number, currency: string) {
|
||||
return `${currency || "CNY"} ${(micros / 1_000_000).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 6 })}`;
|
||||
}
|
||||
|
||||
function categoryLabel(category: string) {
|
||||
return ({ RTC: "实时音视频", END_TO_END_VOICE: "端到端语音", ASR: "语音识别", LLM: "大模型", TTS: "语音合成", STORAGE: "存储", POST_PROCESSING: "后处理" } as Record<string, string>)[category] ?? category;
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return ({ DRAFT: "草稿", PUBLISHED: "已发布", RETIRED: "已退役" } as Record<string, string>)[status] ?? status;
|
||||
}
|
||||
|
||||
function roleLabel(role: string) {
|
||||
return ({ ops_viewer: "运营查看", ops_operator: "运营操作", ops_approver: "运营审批", ops_admin: "运营管理员" } as Record<string, string>)[role] ?? role;
|
||||
}
|
||||
|
||||
function errorText(cause: unknown) {
|
||||
if (cause instanceof APIRequestError) return cause.code ? `${cause.message}(${cause.code})` : cause.message;
|
||||
return cause instanceof Error ? cause.message : "操作失败";
|
||||
}
|
||||
|
||||
function pricePublishReady(draft: PricePublishDraft) { return Boolean(draft.reason.trim() && draft.acknowledged); }
|
||||
@@ -0,0 +1,330 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
APIRequestError, platformApi, type CurrentUser, type OpsConfigEvaluation, type OpsConfigRollout,
|
||||
type OpsConfigTransitionRequest, type OpsConfigVersion, type OpsDeviceGroup,
|
||||
type OpsResourceReference
|
||||
} from "../api/client";
|
||||
|
||||
type Kind = "PERSONA" | "VOICE" | "MODEL" | "ROUTING" | "AGENT_BUNDLE";
|
||||
type Scope = "TENANT" | "SUBJECT" | "DEVICE_GROUP";
|
||||
type BundleField = "persona_version_id" | "voice_version_id" | "model_version_id" | "routing_version_id";
|
||||
type EvaluationDraft = { item: OpsConfigVersion; suiteVersion: string; reportRef: string; result: "" | "PASSED" | "FAILED"; acknowledged: boolean };
|
||||
type ConfigControlDraft =
|
||||
| { kind: "resource_revoke"; item: OpsResourceReference; reason: string; acknowledged: boolean }
|
||||
| { kind: "rollout_pause" | "rollout_resume"; item: OpsConfigRollout; reason: string; acknowledged: boolean };
|
||||
type VersionTransitionDraft =
|
||||
| { kind: "submit"; item: OpsConfigVersion; acknowledged: boolean }
|
||||
| { kind: "publish" | "rollback"; item: OpsConfigVersion; scopeType: Scope; scopeID: string; rolloutPercent: number; maxConcurrentCalls: number; reason: string; acknowledged: boolean };
|
||||
|
||||
const bundleFields: Array<{ field: BundleField; kind: Exclude<Kind, "AGENT_BUNDLE">; label: string }> = [
|
||||
{ field: "persona_version_id", kind: "PERSONA", label: "人设版本" },
|
||||
{ field: "voice_version_id", kind: "VOICE", label: "音色版本" },
|
||||
{ field: "model_version_id", kind: "MODEL", label: "模型版本" },
|
||||
{ field: "routing_version_id", kind: "ROUTING", label: "路由版本" }
|
||||
];
|
||||
|
||||
export function OpsConfigWorkspace({ accessToken, accounts }: { accessToken: string; accounts: CurrentUser["accounts"] }) {
|
||||
const opsAccounts = accounts.filter((item) => item.platform_role !== "customer");
|
||||
const [tenantID, setTenantID] = useState(opsAccounts[0]?.tenant_id ?? "");
|
||||
const account = opsAccounts.find((item) => item.tenant_id === tenantID) ?? opsAccounts[0];
|
||||
const [items, setItems] = useState<OpsConfigVersion[]>([]);
|
||||
const [resourceRefs, setResourceRefs] = useState<OpsResourceReference[]>([]);
|
||||
const [deviceGroups, setDeviceGroups] = useState<OpsDeviceGroup[]>([]);
|
||||
const [rollouts, setRollouts] = useState<OpsConfigRollout[]>([]);
|
||||
const [evaluations, setEvaluations] = useState<Record<string, OpsConfigEvaluation[]>>({});
|
||||
const [evaluationDraft, setEvaluationDraft] = useState<EvaluationDraft>();
|
||||
const [configControlDraft, setConfigControlDraft] = useState<ConfigControlDraft>();
|
||||
const [versionTransitionDraft, setVersionTransitionDraft] = useState<VersionTransitionDraft>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [message, setMessage] = useState<string>();
|
||||
const [kind, setKind] = useState<Kind>("ROUTING");
|
||||
const [configKey, setConfigKey] = useState("default-agent-routing");
|
||||
const [displayName, setDisplayName] = useState("默认语音路由");
|
||||
const [payloadText, setPayloadText] = useState(defaultPayload("ROUTING"));
|
||||
const [secretRefsText, setSecretRefsText] = useState("{}");
|
||||
const [summary, setSummary] = useState("");
|
||||
const [resourceType, setResourceType] = useState<"PROVIDER_CREDENTIAL" | "VOICE_RESOURCE" | "MODEL_RESOURCE">("PROVIDER_CREDENTIAL");
|
||||
const [referenceName, setReferenceName] = useState("");
|
||||
const [referenceDisplayName, setReferenceDisplayName] = useState("");
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [groupDeviceIDs, setGroupDeviceIDs] = useState("");
|
||||
|
||||
const canDraft = account && ["ops_operator", "ops_approver", "ops_admin"].includes(account.platform_role);
|
||||
const canApprove = account && ["ops_approver", "ops_admin"].includes(account.platform_role);
|
||||
|
||||
async function load(signal?: AbortSignal) {
|
||||
if (!tenantID) return;
|
||||
setEvaluationDraft(undefined);
|
||||
setConfigControlDraft(undefined);
|
||||
setVersionTransitionDraft(undefined);
|
||||
setLoading(true); setError(undefined);
|
||||
try {
|
||||
const [versions, refs, deviceGroupResponse, rolloutResponse] = await Promise.all([
|
||||
platformApi.opsConfigVersions(tenantID, accessToken, signal),
|
||||
platformApi.opsResourceReferences(tenantID, accessToken, signal),
|
||||
platformApi.opsDeviceGroups(tenantID, accessToken, signal),
|
||||
platformApi.opsConfigRollouts(tenantID, accessToken, signal)
|
||||
]);
|
||||
setItems(versions.data.items); setResourceRefs(refs.data.items); setDeviceGroups(deviceGroupResponse.data.items); setRollouts(rolloutResponse.data.items);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(errorText(cause));
|
||||
} finally { if (!signal?.aborted) setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController(); void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [tenantID, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
setKind("ROUTING"); setConfigKey("default-agent-routing"); setDisplayName("默认语音路由");
|
||||
setPayloadText(defaultPayload("ROUTING")); setSecretRefsText("{}"); setSummary("");
|
||||
}, [tenantID]);
|
||||
|
||||
const versionGroups = useMemo(() => {
|
||||
const result = new Map<string, OpsConfigVersion[]>();
|
||||
for (const item of items) result.set(item.config_key, [...(result.get(item.config_key) ?? []), item]);
|
||||
return [...result.entries()];
|
||||
}, [items]);
|
||||
|
||||
const bundleCandidates = useMemo(() => {
|
||||
const result = new Map<Exclude<Kind, "AGENT_BUNDLE">, OpsConfigVersion[]>();
|
||||
for (const definition of bundleFields) {
|
||||
result.set(definition.kind, componentCandidates(items, definition.kind));
|
||||
}
|
||||
return result;
|
||||
}, [items]);
|
||||
const bundlePayload = useMemo(() => readBundlePayload(payloadText), [payloadText]);
|
||||
const unavailableBundleKinds = bundleFields.filter((definition) => !(bundleCandidates.get(definition.kind)?.length));
|
||||
const bundleReady = bundleFields.every((definition) => {
|
||||
const selected = bundlePayload[definition.field];
|
||||
return Boolean(selected && bundleCandidates.get(definition.kind)?.some((candidate) => candidate.id === selected));
|
||||
});
|
||||
const controllableRollouts = rollouts.filter((item) => item.status === "ACTIVE" || item.status === "PAUSED");
|
||||
|
||||
function changeKind(next: Kind) {
|
||||
setKind(next); setPayloadText(next === "AGENT_BUNDLE" ? defaultAgentBundlePayload(items) : defaultPayload(next));
|
||||
setSecretRefsText(next === "VOICE" || next === "MODEL" ? JSON.stringify({ credential_ref: "kms://voice-platform/provider/local-dev" }, null, 2) : "{}");
|
||||
const defaults: Record<Kind, [string, string]> = {
|
||||
PERSONA: ["default-persona", "默认长者陪伴人设"], VOICE: ["default-voice", "默认语音音色"],
|
||||
MODEL: ["default-model", "默认对话模型"], ROUTING: ["default-agent-routing", "默认语音路由"],
|
||||
AGENT_BUNDLE: ["default-agent-bundle", "默认Agent配置组合"]
|
||||
};
|
||||
setConfigKey(defaults[next][0]); setDisplayName(defaults[next][1]);
|
||||
}
|
||||
|
||||
async function create(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
let payload: Record<string, unknown>; let secret_refs: Record<string, string>;
|
||||
try { payload = JSON.parse(payloadText); secret_refs = JSON.parse(secretRefsText); }
|
||||
catch { setError("配置正文和密钥引用必须是有效JSON对象。"); return; }
|
||||
if (kind === "AGENT_BUNDLE" && !bundleReady) {
|
||||
setError(unavailableBundleKinds.length ? `请先发布可用的${unavailableBundleKinds.map((item) => item.label).join("、")},再创建Agent配置组合。` : "请选择四个仍可用的已发布或已退役组件版本。");
|
||||
return;
|
||||
}
|
||||
await run(async () => {
|
||||
await platformApi.createOpsConfigVersion(tenantID, { config_key: configKey.trim(), display_name: displayName.trim(), config_kind: kind, payload, secret_refs, change_summary: summary.trim() }, accessToken);
|
||||
setSummary(""); setMessage("新的不可变草稿已创建;提交后还需离线评测通过和另一名审批人员发布。"); await load();
|
||||
});
|
||||
}
|
||||
|
||||
function selectBundleComponent(field: BundleField, versionID: string) {
|
||||
setPayloadText(JSON.stringify({ ...bundlePayload, [field]: versionID }, null, 2));
|
||||
}
|
||||
|
||||
function openVersionTransition(kind: VersionTransitionDraft["kind"], item: OpsConfigVersion) {
|
||||
setEvaluationDraft(undefined); setConfigControlDraft(undefined); setError(undefined); setMessage(undefined);
|
||||
setVersionTransitionDraft(kind === "submit"
|
||||
? { kind, item, acknowledged: false }
|
||||
: { kind, item, scopeType: "TENANT", scopeID: "", rolloutPercent: 100, maxConcurrentCalls: 100, reason: "", acknowledged: false });
|
||||
}
|
||||
|
||||
async function submitVersionTransition(event: FormEvent) {
|
||||
event.preventDefault(); const draft = versionTransitionDraft;
|
||||
if (!draft || !versionTransitionDraftReady(draft)) return;
|
||||
await run(async () => {
|
||||
if (draft.kind === "submit") {
|
||||
await platformApi.submitOpsConfigVersion(tenantID, draft.item, accessToken);
|
||||
} else {
|
||||
const input: OpsConfigTransitionRequest = { reason: draft.reason.trim(), rollout: { scope_type: draft.scopeType, ...(draft.scopeType === "TENANT" ? {} : { scope_id: draft.scopeID }), rollout_percent: draft.scopeType === "SUBJECT" ? 100 : draft.rolloutPercent, max_concurrent_calls: draft.maxConcurrentCalls } };
|
||||
if (draft.kind === "publish") await platformApi.publishOpsConfigVersion(tenantID, draft.item, input, accessToken);
|
||||
else await platformApi.rollbackOpsConfigVersion(tenantID, draft.item, input, accessToken);
|
||||
}
|
||||
setMessage(draft.kind === "submit" ? "配置已通过结构检查并提交审批。" : draft.kind === "publish" ? "配置已按确认范围发布,只影响后续新建通话。" : "已按确认的历史内容和范围创建新的已发布回滚版本。"); await load();
|
||||
});
|
||||
}
|
||||
|
||||
function openEvaluation(item: OpsConfigVersion) {
|
||||
if (item.config_kind === "AGENT_BUNDLE") { setError("Agent配置组合必须使用标准评测工具生成并登记AGENT_QUALITY_V1证据,运营页面不能手工填写通过结论。"); return; }
|
||||
setConfigControlDraft(undefined); setVersionTransitionDraft(undefined); setEvaluationDraft({ item, suiteVersion: "", reportRef: "", result: "", acknowledged: false }); setError(undefined); setMessage(undefined);
|
||||
}
|
||||
|
||||
async function addEvaluation(event: FormEvent) {
|
||||
event.preventDefault(); const draft = evaluationDraft;
|
||||
if (!draft || !evaluationDraftReady(draft)) return;
|
||||
await run(async () => {
|
||||
await platformApi.createOpsConfigEvaluation(tenantID, draft.item.id, { evidence_type: "GENERIC", suite_version: draft.suiteVersion.trim(), result: draft.result as "PASSED" | "FAILED", report_ref: draft.reportRef.trim(), metrics: { registered_from: "operations-workspace", gate: "runtime-config-publish" } }, accessToken);
|
||||
const response = await platformApi.opsConfigEvaluations(tenantID, draft.item.id, accessToken);
|
||||
setEvaluations((current) => ({ ...current, [draft.item.id]: response.data.items })); setEvaluationDraft(undefined);
|
||||
setMessage(`已登记${draft.result === "PASSED" ? "通过" : "失败"}的离线评测证据。`);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadEvaluations(configID: string) {
|
||||
await run(async () => { const response = await platformApi.opsConfigEvaluations(tenantID, configID, accessToken); setEvaluations((current) => ({ ...current, [configID]: response.data.items })); }, false);
|
||||
}
|
||||
|
||||
async function addResourceReference(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await run(async () => {
|
||||
await platformApi.createOpsResourceReference(tenantID, { resource_type: resourceType, reference_name: referenceName.trim(), display_name: referenceDisplayName.trim(), verified_at: new Date().toISOString() }, accessToken);
|
||||
setReferenceName(""); setReferenceDisplayName(""); setMessage("受控资源引用已登记;系统未接收或保存资源密钥明文。"); await load();
|
||||
});
|
||||
}
|
||||
|
||||
function openResourceRevoke(item: OpsResourceReference) {
|
||||
setEvaluationDraft(undefined); setVersionTransitionDraft(undefined); setConfigControlDraft({ kind: "resource_revoke", item, reason: "", acknowledged: false }); setError(undefined); setMessage(undefined);
|
||||
}
|
||||
|
||||
function openRolloutTransition(action: "pause" | "resume", item: OpsConfigRollout) {
|
||||
setEvaluationDraft(undefined); setVersionTransitionDraft(undefined); setConfigControlDraft({ kind: action === "pause" ? "rollout_pause" : "rollout_resume", item, reason: "", acknowledged: false }); setError(undefined); setMessage(undefined);
|
||||
}
|
||||
|
||||
async function submitConfigControl(event: FormEvent) {
|
||||
event.preventDefault(); const draft = configControlDraft;
|
||||
if (!draft || !configControlDraftReady(draft)) return;
|
||||
await run(async () => {
|
||||
if (draft.kind === "resource_revoke") {
|
||||
await platformApi.revokeOpsResourceReference(tenantID, draft.item, draft.reason.trim(), accessToken);
|
||||
setMessage("资源引用已撤销;后续依赖该引用的配置发布将被门禁阻断。");
|
||||
} else if (draft.kind === "rollout_pause") {
|
||||
await platformApi.pauseOpsConfigRollout(tenantID, draft.item, draft.reason.trim(), accessToken);
|
||||
setMessage("配置投放已暂停,后续新建通话不再命中该范围。");
|
||||
} else {
|
||||
await platformApi.resumeOpsConfigRollout(tenantID, draft.item, draft.reason.trim(), accessToken);
|
||||
setMessage("配置投放已恢复,后续新建通话可再次命中该范围。");
|
||||
}
|
||||
await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function addDeviceGroup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const device_ids = groupDeviceIDs.split(/[\s,,;;]+/).map((value) => value.trim()).filter(Boolean);
|
||||
await run(async () => {
|
||||
await platformApi.createOpsDeviceGroup(tenantID, { name: groupName.trim(), selector: { source: "manual" }, device_ids }, accessToken);
|
||||
setGroupName(""); setGroupDeviceIDs(""); setMessage("设备灰度分组已创建,可在发布范围中选择。"); await load();
|
||||
});
|
||||
}
|
||||
|
||||
async function run(operation: () => Promise<void>, clearMessage = true) {
|
||||
setBusy(true); setError(undefined); if (clearMessage) setMessage(undefined);
|
||||
try { await operation(); } catch (cause) { setError(errorText(cause)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
if (!opsAccounts.length) return null;
|
||||
return <section className="ops-workspace" aria-label="内部运营配置工作台">
|
||||
<header className="ops-workspace__head"><div><p className="eyebrow">OPERATIONS CONTROL</p><h2>配置发布与版本控制</h2><p>发布前必须具备通过的离线评测;密钥、模型和音色仅保存经核验的受控引用。</p></div><label><span>运营客户组织</span><select value={tenantID} onChange={(event) => setTenantID(event.target.value)}>{opsAccounts.map((item) => <option key={item.user_id} value={item.tenant_id}>{item.tenant_name} · {opsRoleLabel(item.platform_role)}</option>)}</select></label></header>
|
||||
{error ? <div className="notice notice--error" role="alert"><strong>操作未完成</strong><p>{error}</p></div> : null}
|
||||
{message ? <div className="notice" role="status"><strong>操作成功</strong><p>{message}</p></div> : null}
|
||||
|
||||
{canDraft ? <div className="ops-gate-grid">
|
||||
<form className="ops-gate-card" onSubmit={(event) => void addResourceReference(event)}><strong>受控资源引用</strong><p>引用须在180天内核验;不接收密钥正文。</p><label><span>资源类型</span><select value={resourceType} onChange={(event) => setResourceType(event.target.value as typeof resourceType)}><option value="PROVIDER_CREDENTIAL">供应商凭据</option><option value="VOICE_RESOURCE">音色资源</option><option value="MODEL_RESOURCE">模型资源</option></select></label><label><span>引用标识</span><input value={referenceName} onChange={(event) => setReferenceName(event.target.value)} placeholder="kms://provider/prod" required /></label><label><span>显示名称</span><input value={referenceDisplayName} onChange={(event) => setReferenceDisplayName(event.target.value)} required /></label><button type="submit" disabled={busy}>登记引用</button><small>当前有效引用:{resourceRefs.filter((item) => item.status === "ACTIVE").length}</small></form>
|
||||
<form className="ops-gate-card" onSubmit={(event) => void addDeviceGroup(event)}><strong>设备灰度分组</strong><p>成员必须属于当前客户组织,跨租户编号会被拒绝。</p><label><span>分组名称</span><input value={groupName} onChange={(event) => setGroupName(event.target.value)} required /></label><label><span>设备公开编号</span><textarea rows={3} value={groupDeviceIDs} onChange={(event) => setGroupDeviceIDs(event.target.value)} placeholder="每行一个UUID,也可用逗号分隔" required /></label><button type="submit" disabled={busy}>创建设备组</button><small>当前有效设备组:{deviceGroups.filter((item) => item.status === "ACTIVE").length}</small></form>
|
||||
</div> : null}
|
||||
|
||||
<div className="ops-ledger-grid">
|
||||
<section className="ops-ledger"><div><strong>配置投放控制</strong><span>{controllableRollouts.filter((item) => item.status === "ACTIVE").length} 个活动 · {controllableRollouts.filter((item) => item.status === "PAUSED").length} 个暂停</span></div>{controllableRollouts.length ? <ul>{controllableRollouts.map((item) => <li key={item.id}><span><strong>{item.config_key}</strong><small>{scopeLabel(item.scope_type)}{item.scope_id ? ` · ${item.scope_id}` : ""} · {item.status === "ACTIVE" ? "投放中" : "已暂停"} · 行版本 v{item.row_version}</small></span><span>{item.rollout_percent}% · 并发≤{item.max_concurrent_calls}</span>{canApprove ? <button type="button" className={`button-small ${item.status === "ACTIVE" ? "button-danger" : ""}`} disabled={busy} onClick={() => openRolloutTransition(item.status === "ACTIVE" ? "pause" : "resume", item)}>{item.status === "ACTIVE" ? "暂停" : "恢复"}</button> : null}</li>)}</ul> : <p>尚无活动或暂停的配置投放。</p>}</section>
|
||||
<section className="ops-ledger"><div><strong>资源引用状态</strong><span>{resourceRefs.length} 条</span></div>{resourceRefs.length ? <ul>{resourceRefs.map((item) => <li key={item.id}><span><strong>{item.display_name}</strong><small>{item.reference_name} · {item.status === "ACTIVE" ? "有效" : "已撤销"} · 行版本 v{item.row_version}</small></span>{item.status === "ACTIVE" && canDraft ? <button type="button" className="button-small button-danger" disabled={busy} onClick={() => openResourceRevoke(item)}>撤销</button> : null}</li>)}</ul> : <p>尚未登记资源引用。</p>}</section>
|
||||
</div>
|
||||
|
||||
{configControlDraft ? <form className={`sensitive-access-request controlled-operation ops-config-control ${configControlDraft.kind === "rollout_resume" ? "" : "controlled-operation--danger"}`} aria-label="运营配置受控操作" onSubmit={(event) => void submitConfigControl(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">CONTROLLED CONFIG OPERATION</p><h3>{configControlTitle(configControlDraft.kind)}</h3></div><span className={`status ${configControlDraft.kind === "rollout_resume" ? "warning" : "danger"}`}>{configControlDraft.kind === "rollout_resume" ? "恢复前复核" : "高影响操作"}</span></div>{configControlDraft.kind === "resource_revoke" ? <p>资源 <strong>{configControlDraft.item.id}</strong> · {configControlDraft.item.display_name} · {configControlDraft.item.resource_type} · {configControlDraft.item.reference_name} · 当前有效 · 行版本 v{configControlDraft.item.row_version}。撤销只改变受控引用状态,不删除外部密钥,也不改写已冻结的历史会话。</p> : <p>投放 <strong>{configControlDraft.item.id}</strong> · {configControlDraft.item.config_key} · {scopeLabel(configControlDraft.item.scope_type)}{configControlDraft.item.scope_id ? ` ${configControlDraft.item.scope_id}` : ""} · {configControlDraft.item.status === "ACTIVE" ? "投放中" : "已暂停"} · {configControlDraft.item.rollout_percent}% · 并发≤{configControlDraft.item.max_concurrent_calls} · 行版本 v{configControlDraft.item.row_version}。已建立通话继续使用创建时冻结的配置。</p>}<label><span>{configControlDraft.kind === "resource_revoke" ? "撤销原因" : configControlDraft.kind === "rollout_pause" ? "暂停原因和观察依据" : "恢复原因和确认依据"}</span><textarea autoFocus required maxLength={500} value={configControlDraft.reason} onChange={(event) => setConfigControlDraft({ ...configControlDraft, reason: event.target.value })} placeholder={configControlReasonPlaceholder(configControlDraft.kind)} /></label><label className="controlled-operation__confirmation"><input type="checkbox" checked={configControlDraft.acknowledged} onChange={(event) => setConfigControlDraft({ ...configControlDraft, acknowledged: event.target.checked })} />{configControlAcknowledgement(configControlDraft.kind)}</label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={() => setConfigControlDraft(undefined)}>取消</button><button type="submit" className={configControlDraft.kind === "rollout_resume" ? "" : "button-danger"} disabled={busy || !configControlDraftReady(configControlDraft)}>{busy ? "正在处理…" : configControlSubmitLabel(configControlDraft.kind)}</button></div></form> : null}
|
||||
|
||||
{canDraft ? <form className="ops-config-form" onSubmit={(event) => void create(event)}>
|
||||
<div className="ops-config-form__intro"><strong>新建不可变草稿版本</strong><span>提交时执行结构、必填字段、敏感键和密钥引用检查。</span></div>
|
||||
<label><span>配置类型</span><select value={kind} onChange={(event) => changeKind(event.target.value as Kind)}><option value="PERSONA">人设</option><option value="VOICE">音色</option><option value="MODEL">模型</option><option value="ROUTING">语音路由</option><option value="AGENT_BUNDLE">配置组合</option></select></label>
|
||||
<label><span>版本族配置键</span><input value={configKey} pattern="[a-z][a-z0-9_.-]{2,99}" onChange={(event) => setConfigKey(event.target.value)} required /><small>{kind === "AGENT_BUNDLE" ? "可使用业务自定义键;发布后按灰度范围自动参与新通话选择。" : "同一键下的版本形成连续、可回滚的发布历史。"}</small></label>
|
||||
<label><span>显示名称</span><input value={displayName} maxLength={200} onChange={(event) => setDisplayName(event.target.value)} required /></label>
|
||||
{kind === "AGENT_BUNDLE" ? <fieldset className="ops-bundle-composer ops-config-form__wide"><legend>组合组件版本</legend>{bundleFields.map((definition) => {
|
||||
const candidates = bundleCandidates.get(definition.kind) ?? [];
|
||||
return <label key={definition.field}><span>{definition.label}</span><select value={bundlePayload[definition.field]} disabled={!candidates.length} required onChange={(event) => selectBundleComponent(definition.field, event.target.value)}><option value="">{candidates.length ? "请选择已发布版本" : "暂无可用版本"}</option>{candidates.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.display_name} · {candidate.config_key} v{candidate.version_no}{candidate.status === "RETIRED" ? " · 已退役(可追溯复用)" : " · 已发布"}</option>)}</select></label>;
|
||||
})}<small className={unavailableBundleKinds.length ? "ops-bundle-composer__warning" : ""}>{unavailableBundleKinds.length ? `缺少可用组件:${unavailableBundleKinds.map((item) => item.label).join("、")}。请先完成对应配置的审批发布。` : "仅列出当前客户组织内已经发布过的不可变版本;提交和发布时服务端仍会复核租户、类型与状态。"}</small></fieldset> : <label className="ops-config-form__wide"><span>配置正文(JSON)</span><textarea rows={8} value={payloadText} onChange={(event) => setPayloadText(event.target.value)} spellCheck={false} /></label>}
|
||||
{kind === "AGENT_BUNDLE" ? <p className="ops-config-form__wide ops-config-form__note">组合不直接保存密钥引用,模型和音色凭据由各自已发布组件管理。</p> : <label className="ops-config-form__wide"><span>受控密钥引用(JSON,不得填写密钥明文)</span><textarea rows={3} value={secretRefsText} onChange={(event) => setSecretRefsText(event.target.value)} spellCheck={false} /></label>}
|
||||
<label className="ops-config-form__wide"><span>变更说明</span><input value={summary} maxLength={500} onChange={(event) => setSummary(event.target.value)} placeholder="说明变更目的、预期影响和验证依据" required /></label>
|
||||
<button type="submit" disabled={busy || !summary.trim() || !configKey.trim() || !displayName.trim() || (kind === "AGENT_BUNDLE" && !bundleReady)}>创建草稿</button>
|
||||
</form> : <p className="workspace__readonly-note">当前运营角色只能查看版本历史,不能创建配置。</p>}
|
||||
|
||||
<div className="ops-version-list"><div className="section-title"><h3>配置版本历史</h3><span>{items.length}</span></div>
|
||||
{loading ? <p className="empty-copy">正在加载配置版本…</p> : versionGroups.length ? versionGroups.map(([key, versions]) => <section className="ops-version-group" key={key}><div><strong>{versions[0].display_name}</strong><span>{key} · {configKindLabel(versions[0].config_kind)}</span></div>{versions.map((item, index) => <article className="ops-version" key={item.id}>
|
||||
<div className="ops-version__summary"><strong>v{item.version_no} · {configStatusLabel(item.status)}</strong><span>{item.change_summary}</span><small>行版本 v{item.row_version} · {formatDate(item.updated_at)}</small></div>
|
||||
<details><summary>查看配置与版本差异</summary><div className="ops-diff"><pre>{JSON.stringify(index < versions.length - 1 ? versions[index + 1].payload : {}, null, 2)}</pre><pre>{JSON.stringify(item.payload, null, 2)}</pre></div><p>密钥引用:{Object.values(item.secret_refs).join("、") || "无"}</p></details>
|
||||
{evaluations[item.id] ? <ul className="ops-evidence-list">{evaluations[item.id].map((evaluation) => <li key={evaluation.id}><strong>{evaluation.result === "PASSED" ? "通过" : "失败"}</strong><span>{evaluation.evidence_type === "AGENT_QUALITY_V1" ? "Agent标准质量" : "通用离线"} · {evaluation.suite_version} · {evaluation.report_ref}</span></li>)}</ul> : null}
|
||||
<div className="ops-version__actions"><button type="button" className="button-small" disabled={busy} onClick={() => void loadEvaluations(item.id)}>评测证据</button>{(item.status === "DRAFT" || item.status === "PENDING_APPROVAL") && canDraft && item.config_kind !== "AGENT_BUNDLE" ? <button type="button" className="button-small" disabled={busy} onClick={() => openEvaluation(item)}>登记通用离线评测</button> : null}{item.status === "DRAFT" && canDraft ? <button type="button" className="button-small" disabled={busy} onClick={() => openVersionTransition("submit", item)}>提交审批</button> : null}{item.status === "PENDING_APPROVAL" && canApprove ? <button type="button" className="button-small" disabled={busy} onClick={() => openVersionTransition("publish", item)}>审批并发布</button> : null}{item.status === "RETIRED" && canApprove ? <button type="button" className="button-small button-danger" disabled={busy} onClick={() => openVersionTransition("rollback", item)}>回滚到此内容</button> : null}</div>
|
||||
{versionTransitionDraft?.item.id === item.id ? <form className={`sensitive-access-request controlled-operation ops-version-transition ${versionTransitionDraft.kind === "rollback" ? "controlled-operation--danger" : ""}`} aria-label="配置版本受控流转" onSubmit={(event) => void submitVersionTransition(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">CONTROLLED VERSION TRANSITION</p><h4>{versionTransitionTitle(versionTransitionDraft.kind)}</h4></div><span className={`status ${versionTransitionDraft.kind === "rollback" ? "danger" : "warning"}`}>{versionTransitionDraft.kind === "submit" ? "进入审批" : versionTransitionDraft.kind === "publish" ? "双人审批" : "历史内容回滚"}</span></div><p>配置 <strong>{item.id}</strong> · {configKindLabel(item.config_kind)} · {item.config_key} v{item.version_no} · {configStatusLabel(item.status)} · 行版本 v{item.row_version} · 创建人 {item.created_by}。变更说明:{item.change_summary}</p>{versionTransitionDraft.kind === "submit" ? <p className="recording-notice">本操作只把不可变草稿送入审批,不会发布配置或改变现有投放;提交后必须由另一名具备审批权限的人员完成发布。</p> : <div className="ops-rollout-panel ops-version-transition__scope"><div><strong>本次确认的发布范围</strong><p>范围参数只属于当前固定版本;切换目标或取消后不会保留为其他版本的隐式参数。</p></div><label><span>范围类型</span><select value={versionTransitionDraft.scopeType} onChange={(event) => { const scopeType = event.target.value as Scope; setVersionTransitionDraft({ ...versionTransitionDraft, scopeType, scopeID: "", rolloutPercent: scopeType === "SUBJECT" ? 100 : versionTransitionDraft.rolloutPercent, acknowledged: false }); }}><option value="TENANT">整个客户组织</option><option value="DEVICE_GROUP">设备组</option><option value="SUBJECT">单个试验对象</option></select></label>{versionTransitionDraft.scopeType === "DEVICE_GROUP" ? <label><span>设备组</span><select required value={versionTransitionDraft.scopeID} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, scopeID: event.target.value, acknowledged: false })}><option value="">请选择</option>{deviceGroups.filter((group) => group.status === "ACTIVE").map((group) => <option key={group.id} value={group.id}>{group.name} · {group.device_ids.length}台</option>)}</select></label> : versionTransitionDraft.scopeType === "SUBJECT" ? <label><span>试验对象公开编号</span><input required value={versionTransitionDraft.scopeID} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, scopeID: event.target.value.trim(), acknowledged: false })} placeholder="UUID" /></label> : null}<label><span>灰度比例(%)</span><input type="number" min={1} max={100} value={versionTransitionDraft.scopeType === "SUBJECT" ? 100 : versionTransitionDraft.rolloutPercent} disabled={versionTransitionDraft.scopeType === "SUBJECT"} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, rolloutPercent: Number(event.target.value), acknowledged: false })} /></label><label><span>并发影响上限</span><input type="number" min={1} max={100000} value={versionTransitionDraft.maxConcurrentCalls} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, maxConcurrentCalls: Number(event.target.value), acknowledged: false })} /></label><label className="ops-rollout-panel__reason"><span>{versionTransitionDraft.kind === "publish" ? "审批发布依据" : "回滚依据和故障说明"}</span><textarea autoFocus required maxLength={500} value={versionTransitionDraft.reason} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, reason: event.target.value, acknowledged: false })} placeholder={versionTransitionDraft.kind === "publish" ? "填写评测复核、审批依据、影响范围和观察计划" : "填写故障现象、历史版本选择依据、影响范围和回退观察计划"} /></label></div>}<label className="controlled-operation__confirmation"><input autoFocus={versionTransitionDraft.kind === "submit"} type="checkbox" checked={versionTransitionDraft.acknowledged} onChange={(event) => setVersionTransitionDraft({ ...versionTransitionDraft, acknowledged: event.target.checked })} />{versionTransitionAcknowledgement(versionTransitionDraft)}</label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={() => setVersionTransitionDraft(undefined)}>取消</button><button type="submit" className={versionTransitionDraft.kind === "rollback" ? "button-danger" : ""} disabled={busy || !versionTransitionDraftReady(versionTransitionDraft)}>{busy ? "正在处理…" : versionTransitionSubmitLabel(versionTransitionDraft.kind)}</button></div></form> : null}
|
||||
{evaluationDraft?.item.id === item.id ? <form className="sensitive-access-request controlled-operation ops-evaluation-editor" aria-label="通用离线评测登记" onSubmit={(event) => void addEvaluation(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">GENERIC OFFLINE EVALUATION</p><h4>登记通用离线评测证据</h4></div><span className="status warning">不会替代Agent标准评测</span></div><p>配置 <strong>{item.id}</strong> · {configKindLabel(item.config_kind)} · {item.config_key} v{item.version_no} · 行版本 v{item.row_version}。本表单只登记通用证据,Agent配置组合和标准质量报告必须使用受控评测工具。</p><label><span>评测套件版本</span><input autoFocus required maxLength={100} value={evaluationDraft.suiteVersion} onChange={(event) => setEvaluationDraft({ ...evaluationDraft, suiteVersion: event.target.value })} placeholder="如 voice-routing-regression-v3" /></label><label><span>评测报告引用</span><input required maxLength={300} value={evaluationDraft.reportRef} onChange={(event) => setEvaluationDraft({ ...evaluationDraft, reportRef: event.target.value })} placeholder="填写可追溯的报告编号或受控存储引用" /></label><fieldset><legend>评测结论</legend><label><input type="radio" name={`evaluation-result-${item.id}`} checked={evaluationDraft.result === "PASSED"} onChange={() => setEvaluationDraft({ ...evaluationDraft, result: "PASSED", acknowledged: false })} />通过</label><label><input type="radio" name={`evaluation-result-${item.id}`} checked={evaluationDraft.result === "FAILED"} onChange={() => setEvaluationDraft({ ...evaluationDraft, result: "FAILED", acknowledged: false })} />失败</label></fieldset>{evaluationDraft.result === "PASSED" ? <label className="controlled-operation__confirmation"><input type="checkbox" checked={evaluationDraft.acknowledged} onChange={(event) => setEvaluationDraft({ ...evaluationDraft, acknowledged: event.target.checked })} />我确认报告内容和指标支持“通过”结论,并理解该记录可能参与当前配置的发布门禁</label> : null}<div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={() => setEvaluationDraft(undefined)}>取消</button><button type="submit" disabled={busy || !evaluationDraftReady(evaluationDraft)}>{busy ? "正在登记…" : "登记评测证据"}</button></div></form> : null}
|
||||
{item.config_kind === "AGENT_BUNDLE" && (item.status === "DRAFT" || item.status === "PENDING_APPROVAL") ? <small>Agent配置组合只接受由固定评测集、观测文件和报告共同校验后登记的标准质量证据。</small> : null}
|
||||
</article>)}</section>) : <p className="empty-copy">当前客户组织尚未创建运行配置版本。</p>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function defaultPayload(kind: Kind) {
|
||||
const values: Record<Kind, unknown> = {
|
||||
PERSONA: { system_prompt: "你是一位耐心、尊重长者的陪伴型语音助手。", tone: "warm" },
|
||||
VOICE: { provider: "volcengine", voice_resource_ref: "voice://approved/default" }, MODEL: { provider: "volcengine", model_id: "model://approved/default" },
|
||||
ROUTING: { default_engine_mode: "s2s_hybrid", routes: [{ goal: "open_conversation", engine_mode: "s2s_hybrid" }] },
|
||||
AGENT_BUNDLE: { persona_version_id: "", voice_version_id: "", model_version_id: "", routing_version_id: "" }
|
||||
};
|
||||
return JSON.stringify(values[kind], null, 2);
|
||||
}
|
||||
function readBundlePayload(value: string): Record<BundleField, string> {
|
||||
const empty: Record<BundleField, string> = { persona_version_id: "", voice_version_id: "", model_version_id: "", routing_version_id: "" };
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, unknown>;
|
||||
for (const definition of bundleFields) if (typeof parsed[definition.field] === "string") empty[definition.field] = String(parsed[definition.field]);
|
||||
} catch { /* The create handler owns user-facing JSON errors for non-structured payloads. */ }
|
||||
return empty;
|
||||
}
|
||||
function defaultAgentBundlePayload(items: OpsConfigVersion[]) {
|
||||
const selected = readBundlePayload(defaultPayload("AGENT_BUNDLE"));
|
||||
for (const definition of bundleFields) {
|
||||
const candidate = componentCandidates(items, definition.kind)[0];
|
||||
selected[definition.field] = candidate?.id ?? "";
|
||||
}
|
||||
return JSON.stringify(selected, null, 2);
|
||||
}
|
||||
function componentCandidates(items: OpsConfigVersion[], kind: Exclude<Kind, "AGENT_BUNDLE">) {
|
||||
return items.filter((item) => item.config_kind === kind && (item.status === "PUBLISHED" || item.status === "RETIRED")).sort((left, right) => {
|
||||
if (left.status !== right.status) return left.status === "PUBLISHED" ? -1 : 1;
|
||||
const publishedOrder = new Date(right.published_at ?? right.updated_at).getTime() - new Date(left.published_at ?? left.updated_at).getTime();
|
||||
return publishedOrder || right.version_no - left.version_no || left.config_key.localeCompare(right.config_key);
|
||||
});
|
||||
}
|
||||
function evaluationDraftReady(draft: EvaluationDraft) { return Boolean(draft.suiteVersion.trim() && draft.reportRef.trim() && draft.result && (draft.result !== "PASSED" || draft.acknowledged)); }
|
||||
function configControlDraftReady(draft: ConfigControlDraft) { return Boolean(draft.reason.trim() && draft.acknowledged); }
|
||||
function versionTransitionDraftReady(draft: VersionTransitionDraft) {
|
||||
if (!draft.acknowledged) return false;
|
||||
if (draft.kind === "submit") return true;
|
||||
return Boolean(draft.reason.trim() && (draft.scopeType === "TENANT" || draft.scopeID.trim()) && draft.rolloutPercent >= 1 && draft.rolloutPercent <= 100 && draft.maxConcurrentCalls >= 1 && draft.maxConcurrentCalls <= 100000);
|
||||
}
|
||||
function versionTransitionTitle(kind: VersionTransitionDraft["kind"]) { return ({ submit: "提交配置版本审批", publish: "审批并发布配置版本", rollback: "回滚到历史配置内容" } as const)[kind]; }
|
||||
function versionTransitionSubmitLabel(kind: VersionTransitionDraft["kind"]) { return ({ submit: "确认提交审批", publish: "确认审批并发布", rollback: "确认创建回滚版本" } as const)[kind]; }
|
||||
function versionTransitionAcknowledgement(draft: VersionTransitionDraft) {
|
||||
if (draft.kind === "submit") return "我确认目标版本、变更说明和配置内容已经复核;提交只进入审批,不会直接影响现有或后续通话";
|
||||
const range = `${scopeLabel(draft.scopeType)}${draft.scopeID ? ` ${draft.scopeID}` : ""}、${draft.scopeType === "SUBJECT" ? 100 : draft.rolloutPercent}%灰度、并发上限${draft.maxConcurrentCalls}`;
|
||||
return draft.kind === "publish"
|
||||
? `我确认离线评测、审批职责和发布范围(${range})均已复核;发布只影响后续新建通话,既有通话继续使用冻结配置`
|
||||
: `我确认已核对目标历史内容和回滚范围(${range}),理解系统会创建新的不可变版本并只影响后续新建通话,且已准备观察和再次回退方案`;
|
||||
}
|
||||
function configControlTitle(kind: ConfigControlDraft["kind"]) { return ({ resource_revoke: "撤销受控资源引用", rollout_pause: "暂停配置投放", rollout_resume: "恢复配置投放" } as const)[kind]; }
|
||||
function configControlSubmitLabel(kind: ConfigControlDraft["kind"]) { return ({ resource_revoke: "确认撤销引用", rollout_pause: "确认暂停投放", rollout_resume: "确认恢复投放" } as const)[kind]; }
|
||||
function configControlReasonPlaceholder(kind: ConfigControlDraft["kind"]) { return ({ resource_revoke: "说明撤销依据、替代资源和影响范围", rollout_pause: "说明异常现象、暂停依据和观察计划", rollout_resume: "说明问题已消除的证据、恢复范围和观察计划" } as const)[kind]; }
|
||||
function configControlAcknowledgement(kind: ConfigControlDraft["kind"]) { return ({ resource_revoke: "我确认后续依赖该引用的配置发布将被阻断,并已核对替代资源;本操作不会删除外部密钥或改写历史会话", rollout_pause: "我确认该投放将不再被后续新建通话选中;已建立通话仍继续使用冻结配置", rollout_resume: "我确认目标配置仍可发布、同范围没有其他活动投放,并已准备恢复后的观察和回退方案" } as const)[kind]; }
|
||||
function errorText(value: unknown) { return value instanceof APIRequestError || value instanceof Error ? value.message : "未知错误"; }
|
||||
function opsRoleLabel(value: string) { return ({ ops_viewer: "运营只读", ops_operator: "运营操作", ops_approver: "配置审批", ops_admin: "平台管理员" } as Record<string, string>)[value] ?? value; }
|
||||
function configKindLabel(value: string) { return ({ PERSONA: "人设", VOICE: "音色", MODEL: "模型", ROUTING: "路由", AGENT_BUNDLE: "配置组合" } as Record<string, string>)[value] ?? value; }
|
||||
function configStatusLabel(value: string) { return ({ DRAFT: "草稿", PENDING_APPROVAL: "待审批", PUBLISHED: "已发布", RETIRED: "已退役" } as Record<string, string>)[value] ?? value; }
|
||||
function scopeLabel(value: string) { return ({ TENANT: "客户组织", SUBJECT: "试验对象", DEVICE_GROUP: "设备组" } as Record<string, string>)[value] ?? value; }
|
||||
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)); }
|
||||
@@ -0,0 +1,148 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { APIRequestError, platformApi, type CurrentUser, type IntegrationIssue, type IntegrationIssueSummary, type OpsAcceptanceSnapshot, type OpsAcceptanceSnapshotRequest, type OpsDiagnosticJob, type OpsDiagnosticRecord, type OpsExternalDependency, type OpsPilotDailySnapshot, type OpsPilotDailySnapshotRequest, type OpsPilotWeeklySnapshot, type OpsPilotWeeklySnapshotRequest, type OpsSensitiveAccessRequest, type OpsSensitiveConversation } from "../api/client";
|
||||
|
||||
const fields = [
|
||||
["device_id", "设备编号"], ["conversation_id", "通话编号"], ["room_id", "房间编号"],
|
||||
["provider_task_ref", "供应商任务编号"], ["request_id", "请求关联编号"]
|
||||
] as const;
|
||||
|
||||
type SensitiveAccessDraft = { conversationID: string; reason: string; includeRecording: boolean; validMinutes: 15 | 60 | 240 };
|
||||
type DiagnosticControlKind = "refresh" | "reconcile" | "stop" | "revoke";
|
||||
type DiagnosticControlDraft = { kind: DiagnosticControlKind; item: OpsDiagnosticRecord; reason: string; acknowledged: boolean };
|
||||
type DiagnosticBundleDraft = { field: typeof fields[number][0]; value: string; reason: string };
|
||||
type DiagnosticJobRetryDraft = { job: OpsDiagnosticJob; reason: string; acknowledged: boolean };
|
||||
type IssueAction = "severity" | "take" | "resolve" | "verify" | "close" | "reopen";
|
||||
type IssueActionDraft = { item: IntegrationIssue; action: IssueAction; severity: IntegrationIssue["severity"]; responsibleParty: string; rootCause: string; fixedVersion: string; resolutionResult: string; regressionEvidence: string; regressionConclusion: string; acknowledged: boolean };
|
||||
type SensitiveAccessReviewDraft = { item: OpsSensitiveAccessRequest; decision: "APPROVE" | "REJECT"; reason: string; acknowledged: boolean };
|
||||
|
||||
export function OpsDiagnosticsWorkspace({ accessToken, accounts }: { accessToken: string; accounts: CurrentUser["accounts"] }) {
|
||||
const opsAccounts = accounts.filter((item) => item.platform_role !== "customer");
|
||||
const [tenantID,setTenantID]=useState(opsAccounts[0]?.tenant_id??"");
|
||||
const account=opsAccounts.find((item)=>item.tenant_id===tenantID)??opsAccounts[0];
|
||||
const [field,setField]=useState<typeof fields[number][0]>("device_id");
|
||||
const [value,setValue]=useState(""); const [items,setItems]=useState<OpsDiagnosticRecord[]>([]);
|
||||
const [jobs,setJobs]=useState<OpsDiagnosticJob[]>([]);
|
||||
const [externalDependencies,setExternalDependencies]=useState<OpsExternalDependency[]>([]);
|
||||
const [issues,setIssues]=useState<IntegrationIssue[]>([]); const [issueStatus,setIssueStatus]=useState<IntegrationIssue["status"]|"">("OPEN");
|
||||
const [issueSummary,setIssueSummary]=useState<IntegrationIssueSummary>({total:0,open:0,critical_open:0,high_open:0,release_blocking:0,release_gate_satisfied:true});
|
||||
const [issueDraft,setIssueDraft]=useState({phenomenon:"",severity:"MEDIUM" as IntegrationIssue["severity"],responsible_party:"",device_id:"",conversation_id:"",request_id:"",occurred_at:localDateTimeInput()});
|
||||
const [acceptanceDraft,setAcceptanceDraft]=useState<OpsAcceptanceSnapshotRequest>({candidate_version:"",build_digest:"",environment:"LAB",run_type:"REAL_DEVICE_PROVIDER",scope:"本地真实设备与供应商联调范围",required_dependencies:["oidc_jwks","ark_tls","volc_rtc_tls","content_safety_tls"],reason:"生成阶段8可归档技术预检证据"});
|
||||
const [acceptanceResult,setAcceptanceResult]=useState<OpsAcceptanceSnapshot>();
|
||||
const [pilotDailyDraft,setPilotDailyDraft]=useState<OpsPilotDailySnapshotRequest>({candidate_version:"",build_digest:"",local_date:localDateInput(),timezone:localTimezone(),reason:"归档试点运行日报并复核当日异常与当前积压"});
|
||||
const [pilotDailyResult,setPilotDailyResult]=useState<OpsPilotDailySnapshot>();
|
||||
const [pilotWeeklyDraft,setPilotWeeklyDraft]=useState<OpsPilotWeeklySnapshotRequest>({candidate_version:"",build_digest:"",week_start:localWeekStart(),timezone:localTimezone(),reason:"归档试点周度复盘并补充外部投诉证据"});
|
||||
const [pilotWeeklyResult,setPilotWeeklyResult]=useState<OpsPilotWeeklySnapshot>();
|
||||
const [accessRequests,setAccessRequests]=useState<OpsSensitiveAccessRequest[]>([]); const [sensitive,setSensitive]=useState<OpsSensitiveConversation>();
|
||||
const [sensitiveAccessDraft,setSensitiveAccessDraft]=useState<SensitiveAccessDraft>();
|
||||
const [diagnosticControlDraft,setDiagnosticControlDraft]=useState<DiagnosticControlDraft>();
|
||||
const [diagnosticBundleDraft,setDiagnosticBundleDraft]=useState<DiagnosticBundleDraft>();
|
||||
const [diagnosticJobRetryDraft,setDiagnosticJobRetryDraft]=useState<DiagnosticJobRetryDraft>();
|
||||
const [issueActionDraft,setIssueActionDraft]=useState<IssueActionDraft>();
|
||||
const [sensitiveAccessReviewDraft,setSensitiveAccessReviewDraft]=useState<SensitiveAccessReviewDraft>();
|
||||
const [loading,setLoading]=useState(false); const [busy,setBusy]=useState(false); const [error,setError]=useState<string>(); const [message,setMessage]=useState<string>();
|
||||
const canOperate=Boolean(account&&["ops_operator","ops_admin"].includes(account.platform_role));
|
||||
const canRevoke=account?.platform_role==="ops_admin";
|
||||
const canApprove=Boolean(account&&["ops_approver","ops_admin"].includes(account.platform_role));
|
||||
|
||||
useEffect(()=>{const controller=new AbortController();void loadExternalDependencies(controller.signal);return()=>controller.abort()},[tenantID,accessToken]);
|
||||
useEffect(()=>{const controller=new AbortController();void loadIssues(controller.signal);return()=>controller.abort()},[tenantID,accessToken]);
|
||||
useEffect(()=>{setPilotDailyDraft((current)=>({...current,candidate_version:acceptanceDraft.candidate_version,build_digest:acceptanceDraft.build_digest}));setPilotWeeklyDraft((current)=>({...current,candidate_version:acceptanceDraft.candidate_version,build_digest:acceptanceDraft.build_digest}))},[acceptanceDraft.candidate_version,acceptanceDraft.build_digest]);
|
||||
if(!opsAccounts.length)return null;
|
||||
|
||||
async function search(event?:FormEvent){event?.preventDefault();if(!value.trim())return;setDiagnosticControlDraft(undefined);setDiagnosticBundleDraft(undefined);setLoading(true);setError(undefined);setMessage(undefined);try{const result=await platformApi.searchOpsDiagnostics(tenantID,field,value.trim(),accessToken);setItems(result.data.items)}catch(cause){setError(errorText(cause))}finally{setLoading(false)}}
|
||||
async function loadExternalDependencies(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsExternalDependencies(tenantID,accessToken,signal);setExternalDependencies(result.data.items)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}}
|
||||
function openDiagnosticControl(kind:DiagnosticControlKind,item:OpsDiagnosticRecord){setDiagnosticControlDraft({kind,item,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)}
|
||||
async function executeDiagnosticControl(event:FormEvent){
|
||||
event.preventDefault();const draft=diagnosticControlDraft;if(!draft||!draft.reason.trim()||(diagnosticControlNeedsAcknowledgement(draft.kind)&&!draft.acknowledged))return;
|
||||
const label=diagnosticControlLabel(draft.kind);const item=draft.item;
|
||||
setBusy(true);setError(undefined);try{
|
||||
if(draft.kind==="revoke"){
|
||||
await platformApi.revokeOpsDiagnosticDevice(tenantID,item.device_id,draft.reason.trim(),accessToken);setDiagnosticControlDraft(undefined);await search();setMessage("设备身份已吊销,后续设备签名请求将被拒绝。");return;
|
||||
}
|
||||
const result=draft.kind==="stop"?await platformApi.stopOpsDiagnosticConversation(tenantID,item.conversation_id!,draft.reason.trim(),accessToken):draft.kind==="reconcile"?await platformApi.reconcileOpsDiagnosticConversation(tenantID,item.conversation_id!,draft.reason.trim(),accessToken):await platformApi.refreshOpsDiagnosticDeviceConfig(tenantID,item.device_id,draft.reason.trim(),accessToken);
|
||||
setDiagnosticControlDraft(undefined);await search();setMessage(result.data.queued?`${label}已可靠排队,可用相同编号继续查询。`:`${label}已核对,当前状态无需排队。`);
|
||||
}catch(cause){setError(errorText(cause))}finally{setBusy(false)}
|
||||
}
|
||||
async function loadJobs(){setDiagnosticJobRetryDraft(undefined);setBusy(true);setError(undefined);try{const result=await platformApi.opsDiagnosticJobs(tenantID,accessToken);setJobs(result.data.items)}catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
function openJobRetry(job:OpsDiagnosticJob){setDiagnosticJobRetryDraft({job,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)}
|
||||
async function retryJob(event:FormEvent){event.preventDefault();const draft=diagnosticJobRetryDraft;if(!draft||!draft.reason.trim()||!draft.acknowledged)return;setBusy(true);setError(undefined);try{await platformApi.retryOpsDiagnosticJob(tenantID,draft.job.id,draft.reason.trim(),accessToken);const result=await platformApi.opsDiagnosticJobs(tenantID,accessToken);setJobs(result.data.items);setDiagnosticJobRetryDraft(undefined);setMessage("死信任务已重置为待执行,并记录人工重试审计。")}catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
function openBundleExport(){if(!value.trim())return;setDiagnosticBundleDraft({field,value:value.trim(),reason:""});setError(undefined);setMessage(undefined)}
|
||||
async function exportBundle(event:FormEvent){event.preventDefault();const draft=diagnosticBundleDraft;if(!draft||!draft.reason.trim())return;setBusy(true);setError(undefined);try{const result=await platformApi.exportOpsDiagnosticBundle(tenantID,draft.field,draft.value,draft.reason.trim(),accessToken);downloadJSON(result.data,`diagnostic-${result.data.id}.json`);setDiagnosticBundleDraft(undefined);setMessage("脱敏诊断包已生成并下载,导出原因已记录审计。") }catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
async function exportAcceptanceSnapshot(event:FormEvent){event.preventDefault();setBusy(true);setError(undefined);setMessage(undefined);try{const result=await platformApi.exportOpsAcceptanceSnapshot(tenantID,{...acceptanceDraft,candidate_version:acceptanceDraft.candidate_version.trim(),build_digest:acceptanceDraft.build_digest.trim(),scope:acceptanceDraft.scope.trim(),reason:acceptanceDraft.reason.trim()},accessToken);setAcceptanceResult(result.data);downloadJSON(result.data,`acceptance-${result.data.candidate_version}-${result.data.id}.json`);setMessage(result.data.overall_gate_satisfied?"技术验收快照已下载,当前所选门禁全部通过;仍需完成真实设备、供应商和灰度审批。":"技术验收快照已下载,存在未通过门禁,请按页面明细完成复核。") }catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
async function exportPilotDailySnapshot(event:FormEvent){event.preventDefault();setBusy(true);setError(undefined);setMessage(undefined);try{const result=await platformApi.exportOpsPilotDailySnapshot(tenantID,{...pilotDailyDraft,candidate_version:pilotDailyDraft.candidate_version.trim(),build_digest:pilotDailyDraft.build_digest.trim().toLowerCase(),timezone:pilotDailyDraft.timezone.trim(),reason:pilotDailyDraft.reason.trim()},accessToken);setPilotDailyResult(result.data);downloadJSON(result.data,`pilot-daily-${result.data.local_date}-${result.data.id}.json`);setMessage("试点运行日报已生成并下载;候选构建、自然日活动与当前积压均已冻结,导出原因已记录审计。") }catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
async function exportPilotWeeklySnapshot(event:FormEvent){event.preventDefault();setBusy(true);setError(undefined);setMessage(undefined);try{const result=await platformApi.exportOpsPilotWeeklySnapshot(tenantID,{...pilotWeeklyDraft,candidate_version:pilotWeeklyDraft.candidate_version.trim(),build_digest:pilotWeeklyDraft.build_digest.trim().toLowerCase(),timezone:pilotWeeklyDraft.timezone.trim(),reason:pilotWeeklyDraft.reason.trim()},accessToken);setPilotWeeklyResult(result.data);downloadJSON(result.data,`pilot-weekly-${result.data.week_start}-${result.data.id}.json`);setMessage(result.data.is_complete?"试点运行周报已生成并下载;请把客服或CRM投诉证据与该文件共同归档。":"周中运行快照已生成并下载;自然周尚未结束,不可作为完整周报。") }catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
function toggleAcceptanceDependency(dependency:OpsAcceptanceSnapshotRequest["required_dependencies"][number]){setAcceptanceDraft((current)=>({...current,required_dependencies:current.required_dependencies.includes(dependency)?current.required_dependencies.filter((item)=>item!==dependency):[...current.required_dependencies,dependency]}))}
|
||||
function prefillIssue(item:OpsDiagnosticRecord){setIssueDraft((current)=>({...current,device_id:item.device_id,conversation_id:item.conversation_id||"",request_id:item.last_request_id||"",occurred_at:localDateTimeInput()}));setMessage("已把当前诊断记录的设备、通话和请求编号带入问题登记表,请补充现象与责任方。")}
|
||||
async function loadIssues(signal?:AbortSignal){if(!tenantID)return;setIssueActionDraft(undefined);setBusy(true);setError(undefined);try{const result=await platformApi.opsIntegrationIssues(tenantID,issueStatus,accessToken,signal);setIssues(result.data.items);setIssueSummary(result.data.summary)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}}
|
||||
async function createIssue(event:FormEvent){event.preventDefault();if(!issueDraft.phenomenon.trim()||!issueDraft.responsible_party.trim()||!issueDraft.occurred_at)return;setBusy(true);setError(undefined);try{await platformApi.createOpsIntegrationIssue(tenantID,{phenomenon:issueDraft.phenomenon.trim(),severity:issueDraft.severity,responsible_party:issueDraft.responsible_party.trim(),occurred_at:new Date(issueDraft.occurred_at).toISOString(),...(issueDraft.device_id.trim()?{device_id:issueDraft.device_id.trim()}:{}),...(issueDraft.conversation_id.trim()?{conversation_id:issueDraft.conversation_id.trim()}:{}),...(issueDraft.request_id.trim()?{request_id:issueDraft.request_id.trim()}: {})},accessToken);setIssueDraft((current)=>({...current,phenomenon:"",device_id:"",conversation_id:"",request_id:"",occurred_at:localDateTimeInput()}));setIssueStatus("OPEN");setMessage("联调问题已登记,严重级别、责任方和关联编号已写入审计。");const result=await platformApi.opsIntegrationIssues(tenantID,"OPEN",accessToken);setIssues(result.data.items);setIssueSummary(result.data.summary)}catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
function openIssueAction(item:IntegrationIssue,action:IssueAction){setIssueActionDraft({item,action,severity:item.severity,responsibleParty:item.responsible_party,rootCause:item.root_cause||"",fixedVersion:item.fixed_version||"",resolutionResult:item.resolution_result||"",regressionEvidence:item.regression_evidence||"",regressionConclusion:item.regression_conclusion||"",acknowledged:false});setError(undefined);setMessage(undefined)}
|
||||
async function updateIssue(event:FormEvent){
|
||||
event.preventDefault();const draft=issueActionDraft;if(!draft||!issueActionReady(draft))return;
|
||||
let input:Parameters<typeof platformApi.updateOpsIntegrationIssue>[2];
|
||||
if(draft.action==="severity")input={severity:draft.severity};
|
||||
else if(draft.action==="take"||draft.action==="reopen")input={status:"IN_PROGRESS",responsible_party:draft.responsibleParty.trim()};
|
||||
else if(draft.action==="resolve")input={status:"RESOLVED",root_cause:draft.rootCause.trim(),fixed_version:draft.fixedVersion.trim(),resolution_result:draft.resolutionResult.trim()};
|
||||
else if(draft.action==="verify")input={status:"VERIFIED",regression_evidence:draft.regressionEvidence.trim(),regression_conclusion:draft.regressionConclusion.trim()};
|
||||
else input={status:"CLOSED"};
|
||||
setBusy(true);setError(undefined);try{await platformApi.updateOpsIntegrationIssue(tenantID,draft.item,input,accessToken);setIssueActionDraft(undefined);await loadIssues();setMessage("联调问题已更新,并使用ETag防止覆盖他人修改。") }catch(cause){setError(errorText(cause))}finally{setBusy(false)}
|
||||
}
|
||||
async function loadAccessRequests(){setSensitiveAccessReviewDraft(undefined);setBusy(true);setError(undefined);try{const result=await platformApi.opsSensitiveAccessRequests(tenantID,accessToken);setAccessRequests(result.data.items)}catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
function openSensitiveAccessRequest(conversationID:string){setSensitiveAccessDraft({conversationID,reason:"",includeRecording:false,validMinutes:60});setError(undefined);setMessage(undefined)}
|
||||
async function requestSensitiveAccess(event:FormEvent){event.preventDefault();const draft=sensitiveAccessDraft;if(!draft||!draft.reason.trim())return;setBusy(true);setError(undefined);try{await platformApi.createOpsSensitiveAccessRequest(tenantID,draft.conversationID,draft.includeRecording?["TRANSCRIPT_READ","RECORDING_PLAY"]:["TRANSCRIPT_READ"],draft.reason.trim(),new Date(Date.now()+draft.validMinutes*60*1000).toISOString(),accessToken);setSensitiveAccessDraft(undefined);setMessage(`临时访问申请已提交,需由另一名审批人员批准;${draft.validMinutes===15?"15分钟":draft.validMinutes===60?"1小时":"4小时"}后自动到期。`);await loadAccessRequests()}catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
function openSensitiveAccessReview(item:OpsSensitiveAccessRequest,decision:"APPROVE"|"REJECT"){setSensitiveAccessReviewDraft({item,decision,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)}
|
||||
async function reviewAccess(event:FormEvent){event.preventDefault();const draft=sensitiveAccessReviewDraft;if(!draft||!draft.reason.trim()||(draft.decision==="APPROVE"&&!draft.acknowledged))return;setBusy(true);setError(undefined);try{await platformApi.reviewOpsSensitiveAccessRequest(tenantID,draft.item.id,draft.decision,draft.reason.trim(),accessToken);setSensitiveAccessReviewDraft(undefined);const result=await platformApi.opsSensitiveAccessRequests(tenantID,accessToken);setAccessRequests(result.data.items);setMessage(draft.decision==="APPROVE"?"临时访问已批准,到期自动失效。":"临时访问申请已拒绝。") }catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
async function readSensitive(item:OpsSensitiveAccessRequest){setBusy(true);setError(undefined);try{const result=await platformApi.opsSensitiveConversation(tenantID,item.conversation_id,item.id,accessToken);setSensitive(result.data);setMessage("敏感内容已按批准范围读取,本次访问已写入独立审计。") }catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
async function playSensitive(segmentID:string){if(!sensitive)return;setBusy(true);setError(undefined);try{const result=await platformApi.createOpsSensitiveRecordingAccess(tenantID,segmentID,sensitive.grant_id,accessToken);window.open(result.data.access_url,"_blank","noopener,noreferrer");setMessage("已签发5分钟录音播放地址,本次访问已审计。") }catch(cause){setError(errorText(cause))}finally{setBusy(false)}}
|
||||
return <section className="ops-workspace ops-diagnostics" aria-label="联调诊断工作台">
|
||||
<div className="ops-workspace__head"><div><p className="eyebrow">INTEGRATION DIAGNOSTICS</p><h2>设备与通话联调</h2><p>只展示脱敏运行状态,不展示完整短期凭证、字幕正文或原录音。</p></div><label>客户组织<select value={tenantID} onChange={(event)=>{setTenantID(event.target.value);setItems([]);setJobs([]);setIssues([]);setAccessRequests([]);setAcceptanceResult(undefined);setPilotDailyResult(undefined);setPilotWeeklyResult(undefined);setDiagnosticControlDraft(undefined);setDiagnosticBundleDraft(undefined);setDiagnosticJobRetryDraft(undefined);setIssueActionDraft(undefined);setSensitiveAccessDraft(undefined);setSensitiveAccessReviewDraft(undefined);setSensitive(undefined)}}>{opsAccounts.map((item)=><option value={item.tenant_id} key={item.user_id}>{item.tenant_name}</option>)}</select></label></div>
|
||||
{canOperate?<section className="diagnostic-jobs acceptance-snapshot"><div className="section-heading"><div><p className="eyebrow">RELEASE CANDIDATE EVIDENCE</p><h3>发布候选验收快照</h3><p>一次冻结候选构建、迁移、必需依赖、联调问题、可靠任务和严重告警;输出仅是技术预检证据,不代替发布批准。</p></div></div><form className="integration-issue-form" onSubmit={(event)=>void exportAcceptanceSnapshot(event)}><label><span>候选版本</span><input required maxLength={128} pattern="[A-Za-z0-9][A-Za-z0-9._+\-]*" value={acceptanceDraft.candidate_version} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,candidate_version:event.target.value})} placeholder="如 0.55.0-rc.1" /></label><label><span>构建摘要</span><input required pattern="sha256:[A-Fa-f0-9]{64}" value={acceptanceDraft.build_digest} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,build_digest:event.target.value})} placeholder="sha256: 后跟64位十六进制" /></label><label><span>目标环境</span><select value={acceptanceDraft.environment} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,environment:event.target.value as OpsAcceptanceSnapshotRequest["environment"]})}><option value="LAB">实验室</option><option value="STAGING">预发布</option><option value="PRODUCTION">生产</option></select></label><label><span>验收类型</span><select value={acceptanceDraft.run_type} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,run_type:event.target.value as OpsAcceptanceSnapshotRequest["run_type"]})}><option value="REAL_DEVICE_PROVIDER">真实设备与供应商</option><option value="PILOT">灰度试点</option><option value="PRODUCTION_RELEASE">生产发布</option></select></label><label><span>验收范围</span><textarea required maxLength={500} value={acceptanceDraft.scope} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,scope:event.target.value})} /></label><label><span>归档原因</span><textarea required maxLength={255} value={acceptanceDraft.reason} onChange={(event)=>setAcceptanceDraft({...acceptanceDraft,reason:event.target.value})} /></label><fieldset><legend>本次必需依赖</legend>{(["oidc_jwks","ark_tls","volc_rtc_tls","content_safety_tls"] as const).map((dependency)=><label className="checkbox-label" key={dependency}><input type="checkbox" checked={acceptanceDraft.required_dependencies.includes(dependency)} onChange={()=>toggleAcceptanceDependency(dependency)} />{externalDependencyLabel(dependency)}</label>)}</fieldset><button type="submit" disabled={busy||!acceptanceDraft.candidate_version.trim()||!acceptanceDraft.build_digest.trim()||!acceptanceDraft.scope.trim()||!acceptanceDraft.reason.trim()||acceptanceDraft.required_dependencies.length===0}>{busy?"生成中…":"生成并下载快照"}</button></form>{acceptanceResult?<div className="acceptance-result"><div className="section-heading"><div><h4>最近生成:{acceptanceResult.candidate_version}</h4><p>{formatTimestamp(acceptanceResult.generated_at)} · 快照 {acceptanceResult.id}</p></div><span className={`status ${acceptanceResult.overall_gate_satisfied?"active":"danger"}`}>{acceptanceResult.overall_gate_satisfied?"技术门禁通过":"技术门禁阻断"}</span></div><div className="issue-summary-grid">{acceptanceResult.gates.map((gate)=><article className={gate.satisfied?"issue-gate-pass":"issue-gate-blocked"} key={gate.code}><span>{acceptanceGateLabel(gate.code)}</span><strong>{gate.satisfied?"通过":"阻断"}</strong><small>{gate.summary}</small></article>)}</div>{acceptanceResult.alerts.blocking_items.length?<div className="diagnostic-results"><h4>阻断告警定位</h4>{acceptanceResult.alerts.blocking_items.map((alert)=><article className="diagnostic-card" key={alert.id}><div className="diagnostic-card__head"><div><strong>{alert.rule_code}</strong><span>告警 {alert.id} · 最近命中 {formatTimestamp(alert.last_detected_at)}</span></div><span className={`status ${alert.severity==="CRITICAL"?"danger":"warning"}`}>{alert.severity}</span></div><dl><div><dt>处置状态</dt><dd>{alert.status}</dd></div><div><dt>关联类型</dt><dd>{alert.resource_type||"聚合队列"}</dd></div><div><dt>关联编号</dt><dd>{alert.resource_id||"—"}</dd></div><div><dt>资源核对</dt><dd>{acceptanceResourceStateLabel(alert.resource_state)}</dd></div></dl></article>)}</div>:<p className="empty-copy">当前没有阻断发布的HIGH或CRITICAL告警。</p>}{acceptanceResult.alerts.blocking_items_truncated?<p className="recording-notice recording-notice--error">阻断告警超过100条,下载文件仅包含最早100条;必须在告警工作台查看完整队列。</p>:null}<p className="empty-copy">{acceptanceResult.disclaimer}</p></div>:null}</section>:null}
|
||||
{canOperate?<section className="diagnostic-jobs pilot-daily-snapshot"><div className="section-heading"><div><p className="eyebrow">PILOT DAILY OPERATIONS</p><h3>试点运行日报</h3><p>按业务时区自然日汇总设备、通话、供应商、上传、告警和费用;下载内容不含字幕、录音、个人资料和供应商任务编号。</p></div></div><form className="integration-issue-form" onSubmit={(event)=>void exportPilotDailySnapshot(event)}><label><span>业务日期</span><input required type="date" max={localDateInput()} value={pilotDailyDraft.local_date} onChange={(event)=>setPilotDailyDraft({...pilotDailyDraft,local_date:event.target.value})} /></label><label><span>IANA 时区</span><input required maxLength={80} value={pilotDailyDraft.timezone} onChange={(event)=>setPilotDailyDraft({...pilotDailyDraft,timezone:event.target.value})} placeholder="Asia/Shanghai" /></label><label><span>归档原因</span><textarea required maxLength={255} value={pilotDailyDraft.reason} onChange={(event)=>setPilotDailyDraft({...pilotDailyDraft,reason:event.target.value})} /></label><button type="submit" disabled={busy||!pilotDailyDraft.candidate_version.trim()||!pilotDailyDraft.build_digest.trim()||!pilotDailyDraft.local_date||!pilotDailyDraft.timezone.trim()||!pilotDailyDraft.reason.trim()}>{busy?"生成中…":"生成并下载日报"}</button></form>{pilotDailyResult?<div className="acceptance-result"><div className="section-heading"><div><h4>最近生成:{pilotDailyResult.local_date}</h4><p>{pilotDailyResult.timezone} · {formatTimestamp(pilotDailyResult.generated_at)} · 日报 {pilotDailyResult.id}</p></div><span className="status active">已脱敏归档</span></div><div className="issue-summary-grid"><article><span>可用设备</span><strong>{pilotDailyResult.devices.eligible}</strong><small>当前在线 {pilotDailyResult.devices.online}</small></article><article><span>当日通话</span><strong>{pilotDailyResult.conversations.total}</strong><small>失败 {pilotStatusCount(pilotDailyResult,"FAILED")}</small></article><article><span>上传积压</span><strong>{pilotDailyResult.uploads.open_batches}</strong><small>待处理分段 {pilotDailyResult.uploads.pending_segments}</small></article><article className={pilotDailyResult.alerts.high_open+pilotDailyResult.alerts.critical_open?"issue-gate-blocked":"issue-gate-pass"}><span>高严重度告警</span><strong>{pilotDailyResult.alerts.high_open+pilotDailyResult.alerts.critical_open}</strong><small>全部未关闭 {pilotDailyResult.alerts.open}</small></article></div><div className="diagnostic-results"><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>供应商运行</strong><span>{pilotDailyResult.providers.length} 个供应商有运行记录</span></div></div><p>{pilotDailyResult.providers.length?pilotDailyResult.providers.map((item)=>`${item.provider}:${item.current_status},失败通知${item.failed_events_during_day},失败通话${item.failed_calls_during_day},积压${item.notification_backlog}`).join(";"):"所选日期和当前状态中没有供应商记录。"}</p></article><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>当日费用</strong><span>按币种分列,不做汇率换算</span></div></div><p>{pilotDailyResult.costs.length?pilotDailyResult.costs.map((item)=>`${item.currency} ${(item.cost_micros/1_000_000).toFixed(6)}(${item.line_count}条)`).join(";"):"所选自然日没有费用明细。"}</p></article></div><p className="empty-copy">{pilotDailyResult.disclaimer}</p></div>:null}</section>:null}
|
||||
{canOperate?<section className="diagnostic-jobs pilot-weekly-snapshot"><div className="section-heading"><div><p className="eyebrow">PILOT WEEKLY REVIEW</p><h3>试点运行周报</h3><p>按周一开始的业务自然周汇总内容审核、记忆、隐私请求、固件和配置差异;投诉仍需附客服或CRM权威证据。</p></div></div><form className="integration-issue-form" onSubmit={(event)=>void exportPilotWeeklySnapshot(event)}><label><span>周开始日期(周一)</span><input required type="date" max={localDateInput()} value={pilotWeeklyDraft.week_start} onChange={(event)=>setPilotWeeklyDraft({...pilotWeeklyDraft,week_start:event.target.value})} /></label><label><span>IANA 时区</span><input required maxLength={80} value={pilotWeeklyDraft.timezone} onChange={(event)=>setPilotWeeklyDraft({...pilotWeeklyDraft,timezone:event.target.value})} placeholder="Asia/Shanghai" /></label><label><span>归档原因</span><textarea required maxLength={255} value={pilotWeeklyDraft.reason} onChange={(event)=>setPilotWeeklyDraft({...pilotWeeklyDraft,reason:event.target.value})} /></label><button type="submit" disabled={busy||!pilotWeeklyDraft.candidate_version.trim()||!pilotWeeklyDraft.build_digest.trim()||!pilotWeeklyDraft.week_start||!pilotWeeklyDraft.timezone.trim()||!pilotWeeklyDraft.reason.trim()}>{busy?"生成中…":"生成并下载周报"}</button></form>{pilotWeeklyResult?<div className="acceptance-result"><div className="section-heading"><div><h4>最近生成:{pilotWeeklyResult.week_start} 起始周</h4><p>{pilotWeeklyResult.timezone} · {formatTimestamp(pilotWeeklyResult.generated_at)} · 周报 {pilotWeeklyResult.id}</p></div><span className={`status ${pilotWeeklyResult.is_complete?"active":"warning"}`}>{pilotWeeklyResult.is_complete?"完整自然周":"周中快照"}</span></div><div className="issue-summary-grid"><article><span>人工修订率</span><strong>{basisPointsLabel(pilotWeeklyResult.content_review.manual_revision_rate_available,pilotWeeklyResult.content_review.manual_revision_rate_basis_points)}</strong><small>{pilotWeeklyResult.content_review.stories_manually_revised}/{pilotWeeklyResult.content_review.stories_review_touched} 个审核故事</small></article><article><span>故事确认率</span><strong>{basisPointsLabel(pilotWeeklyResult.content_review.story_confirmation_rate_available,pilotWeeklyResult.content_review.story_confirmation_rate_basis_points)}</strong><small>确认 {pilotWeeklyResult.content_review.stories_confirmed} · 拒绝 {pilotWeeklyResult.content_review.stories_rejected}</small></article><article><span>隐私请求</span><strong>{pilotWeeklyResult.privacy.requests_created_during_week}</strong><small>当前未完成 {pilotWeeklyResult.privacy.current_open}</small></article><article className={pilotWeeklyResult.config.drifted+pilotWeeklyResult.config.unreported?"issue-gate-blocked":"issue-gate-pass"}><span>配置差异</span><strong>{pilotWeeklyResult.config.drifted+pilotWeeklyResult.config.unreported}</strong><small>一致 {pilotWeeklyResult.config.aligned}/{pilotWeeklyResult.config.eligible_devices}</small></article></div><div className="diagnostic-results"><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>记忆确认与索引</strong><span>首版 {pilotWeeklyResult.content_review.memories_created} · 修订 {pilotWeeklyResult.content_review.memory_revisions}</span></div></div><p>当前索引待处理 {pilotWeeklyResult.content_review.current_memory_index_pending},失败 {pilotWeeklyResult.content_review.current_memory_index_failed}。</p></article><article className="diagnostic-card"><div className="diagnostic-card__head"><div><strong>固件分布</strong><span>{pilotWeeklyResult.firmware_versions.reduce((sum,item)=>sum+item.device_count,0)} 台可用设备</span></div></div><p>{pilotWeeklyResult.firmware_versions.length?pilotWeeklyResult.firmware_versions.map((item)=>`${item.model}/${item.firmware_version}:${item.device_count}台`).join(";"):"当前没有可用且已绑定设备。"}</p></article></div>{pilotWeeklyResult.external_evidence.map((item)=><p className="recording-notice recording-notice--error" key={item.code}><strong>外部证据仍需补充:投诉</strong><br />{item.reason}</p>)}<p className="empty-copy">{pilotWeeklyResult.disclaimer}</p></div>:null}</section>:null}
|
||||
<section className="diagnostic-jobs external-dependencies"><div className="section-heading"><div><p className="eyebrow">EXTERNAL PREFLIGHT</p><h3>外部依赖预检</h3><p>API与Worker周期执行不消耗模型或语音额度的连通性检查,仅保存脱敏结果。</p></div><button type="button" className="button-secondary" disabled={busy} onClick={()=>void loadExternalDependencies()}>刷新状态</button></div>{externalDependencies.length?<div className="external-dependency-grid">{externalDependencies.map((item)=>{const state=externalDependencyState(item);return <article className="diagnostic-card" key={`${item.service_name}-${item.dependency}`}><div className="diagnostic-card__head"><div><strong>{externalDependencyLabel(item.dependency)}</strong><span>{serviceLabel(item.service_name)} · 最近检查 {formatTimestamp(item.checked_at)}</span></div><span className={`status ${state.tone}`}>{state.label}</span></div><dl><div><dt>探针耗时</dt><dd>{item.latency_ms}毫秒</dd></div><div><dt>连续失败</dt><dd>{item.consecutive_failures}次</dd></div><div><dt>最近成功</dt><dd>{formatTimestamp(item.last_success_at)}</dd></div><div><dt>最近失败</dt><dd>{formatTimestamp(item.last_failure_at)}</dd></div></dl>{state.stale?<p>该进程已超过三倍探针周期未更新,不能视为当前可用。</p>:item.last_error_code?<p>归一化错误:<strong>{item.last_error_code}</strong></p>:null}</article>})}</div>:<p className="empty-copy">当前进程尚未写入外部探针结果;本地未启用的方舟或RTC依赖不会伪装为已验收。</p>}</section>
|
||||
<form className="diagnostic-search" onSubmit={(event)=>void search(event)}><label>检索字段<select value={field} onChange={(event)=>setField(event.target.value as typeof field)}>{fields.map(([key,label])=><option value={key} key={key}>{label}</option>)}</select></label><label>精确编号<input value={value} onChange={(event)=>setValue(event.target.value)} placeholder="输入完整编号" /></label><button type="submit" disabled={loading||!value.trim()}>{loading?"查询中…":"查询"}</button>{canOperate?<button type="button" className="button-secondary" disabled={busy||!value.trim()} onClick={openBundleExport}>导出脱敏诊断包</button>:null}</form>
|
||||
{diagnosticBundleDraft?<form className="sensitive-access-request controlled-operation" aria-label="脱敏诊断包导出" onSubmit={(event)=>void exportBundle(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">REDACTED DIAGNOSTIC EXPORT</p><h3>导出脱敏诊断包</h3></div><span className="status warning">导出行为写入审计</span></div><p>本次导出固定使用“<strong>{diagnosticFieldLabel(diagnosticBundleDraft.field)}</strong> = <strong>{diagnosticBundleDraft.value}</strong>”的精确检索条件。文件只包含当前脱敏诊断快照,不包含字幕、录音地址、短期凭证或密钥正文。</p><label><span>导出原因</span><textarea autoFocus required maxLength={500} value={diagnosticBundleDraft.reason} onChange={(event)=>setDiagnosticBundleDraft({...diagnosticBundleDraft,reason:event.target.value})} placeholder="说明接收方、联调问题和使用范围;不要粘贴敏感正文" /></label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={()=>setDiagnosticBundleDraft(undefined)}>取消</button><button type="submit" disabled={busy||!diagnosticBundleDraft.reason.trim()}>{busy?"正在生成…":"生成并下载"}</button></div></form>:null}
|
||||
{error?<div className="notice notice--error" role="alert"><strong>联调操作未完成</strong><p>{error}</p></div>:null}{message?<div className="notice"><strong>操作已受理</strong><p>{message}</p></div>:null}
|
||||
<div className="diagnostic-results">{!loading&&!items.length?<p className="empty-copy">输入任一完整业务编号开始查询。</p>:items.map((item)=><article className="diagnostic-card" key={`${item.device_id}-${item.conversation_id??"device"}`}><div className="diagnostic-card__head"><div><strong>设备 {item.device_id}</strong><span>{item.device_online?"在线":"离线"} · 固件 {item.firmware_version||"未上报"} · 配置 {item.reported_config_revision??"未上报"}/{item.desired_config_revision}</span></div><span className={`status ${item.device_online?"active":"neutral"}`}>{item.device_status}</span></div>{item.conversation_failure_code?<p className="recording-notice recording-notice--error"><strong>{diagnosticFailureLabel(item.conversation_failure_code)}</strong><br />失败标识:{item.conversation_failure_code}</p>:null}<dl><div><dt>当前通话</dt><dd>{item.conversation_id||"无"}</dd></div><div><dt>通话 / RTC状态</dt><dd>{item.conversation_status||"—"} / {item.provider_task_status||"—"}</dd></div><div><dt>房间</dt><dd>{item.room_id||"—"}</dd></div><div><dt>短期凭证签发</dt><dd>{formatTimestamp(item.credential_issued_at)}</dd></div><div><dt>短期凭证过期</dt><dd>{formatTimestamp(item.credential_expires_at)}</dd></div><div><dt>供应商任务</dt><dd>{item.provider_task_ref||"—"}</dd></div><div><dt>最近通知</dt><dd>{item.last_provider_event||"—"} · 累计{item.provider_notification_count}条</dd></div><div><dt>最近错误 / 请求</dt><dd>{item.last_device_error_code||"无"} / {item.last_request_id||"无"}</dd></div><div><dt>房间资料签发耗时</dt><dd>{formatDuration(item.timings?.room_issue_ms)}</dd></div><div><dt>设备入房确认耗时</dt><dd>{formatDuration(item.timings?.device_join_ms)}</dd></div><div><dt>智能体启动耗时</dt><dd>{formatDuration(item.timings?.agent_start_ms)}</dd></div><div><dt>智能体停止耗时</dt><dd>{formatDuration(item.timings?.agent_stop_ms)}</dd></div></dl><div className="row-actions">{canOperate?<><button type="button" className="button-secondary" disabled={busy} onClick={()=>prefillIssue(item)}>登记联调问题</button><button type="button" className="button-secondary" disabled={busy} onClick={()=>openDiagnosticControl("refresh",item)}>刷新配置</button><button type="button" className="button-secondary" disabled={busy||!item.conversation_id} onClick={()=>openDiagnosticControl("reconcile",item)}>状态对账</button><button type="button" className="button-danger" disabled={busy||!item.conversation_id||["COMPLETED","FAILED","EXPIRED"].includes(item.conversation_status??"")} onClick={()=>openDiagnosticControl("stop",item)}>安全停止</button>{canRevoke?<button type="button" className="button-danger" disabled={busy||item.device_status==="revoked"} onClick={()=>openDiagnosticControl("revoke",item)}>吊销设备</button>:null}</>:null}<button type="button" className="button-secondary" disabled={busy||!item.conversation_id} onClick={()=>item.conversation_id&&openSensitiveAccessRequest(item.conversation_id)}>申请临时敏感访问</button></div></article>)}</div>
|
||||
{diagnosticControlDraft?<form className={`sensitive-access-request controlled-operation${diagnosticControlNeedsAcknowledgement(diagnosticControlDraft.kind)?" controlled-operation--danger":""}`} aria-label="受控联调操作" onSubmit={(event)=>void executeDiagnosticControl(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">CONTROLLED DIAGNOSTIC ACTION</p><h3>{diagnosticControlLabel(diagnosticControlDraft.kind)}</h3></div><span className={`status ${diagnosticControlNeedsAcknowledgement(diagnosticControlDraft.kind)?"danger":"warning"}`}>{diagnosticControlNeedsAcknowledgement(diagnosticControlDraft.kind)?"需要显式确认":"记录操作原因"}</span></div><p>{diagnosticControlDescription(diagnosticControlDraft.kind)}<br />目标设备 <strong>{diagnosticControlDraft.item.device_id}</strong>{diagnosticControlDraft.kind!=="refresh"&&diagnosticControlDraft.kind!=="revoke"?<>;目标通话 <strong>{diagnosticControlDraft.item.conversation_id}</strong></>:null}</p><label><span>操作原因</span><textarea autoFocus required maxLength={500} value={diagnosticControlDraft.reason} onChange={(event)=>setDiagnosticControlDraft({...diagnosticControlDraft,reason:event.target.value})} placeholder="说明当前异常、已完成的核对和执行依据;不要粘贴字幕、录音、凭据或签名地址" /></label>{diagnosticControlNeedsAcknowledgement(diagnosticControlDraft.kind)?<label className="controlled-operation__confirmation"><input type="checkbox" checked={diagnosticControlDraft.acknowledged} onChange={(event)=>setDiagnosticControlDraft({...diagnosticControlDraft,acknowledged:event.target.checked})} />{diagnosticControlDraft.kind==="revoke"?"我确认吊销后该设备的后续签名请求会被拒绝":"我确认停止操作只针对上方指定通话,并已核对当前状态"}</label>:null}<div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={()=>setDiagnosticControlDraft(undefined)}>取消</button><button type="submit" className={diagnosticControlNeedsAcknowledgement(diagnosticControlDraft.kind)?"button-danger":undefined} disabled={busy||!diagnosticControlDraft.reason.trim()||(diagnosticControlNeedsAcknowledgement(diagnosticControlDraft.kind)&&!diagnosticControlDraft.acknowledged)}>{busy?"正在执行…":diagnosticControlLabel(diagnosticControlDraft.kind)}</button></div></form>:null}
|
||||
{sensitiveAccessDraft?<form className="sensitive-access-request" aria-label="临时敏感访问申请" onSubmit={(event)=>void requestSensitiveAccess(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">MINIMUM TEMPORARY ACCESS</p><h3>申请单次通话限时访问</h3></div><span className="status warning">需要他人审批</span></div><p>申请只绑定通话 <strong>{sensitiveAccessDraft.conversationID}</strong>。最终字幕读取为必需能力;原录音播放必须单独勾选,且仍会复核原录音授权。平台不提供运营录音下载。</p><label><span>排障理由</span><textarea autoFocus required maxLength={500} value={sensitiveAccessDraft.reason} onChange={(event)=>setSensitiveAccessDraft({...sensitiveAccessDraft,reason:event.target.value})} placeholder="说明具体不一致、错误码或需要核对的范围;不要粘贴字幕、录音、凭据或签名地址" /></label><fieldset><legend>申请能力</legend><label><input type="checkbox" checked disabled />读取本次通话最终字幕</label><label><input type="checkbox" checked={sensitiveAccessDraft.includeRecording} onChange={(event)=>setSensitiveAccessDraft({...sensitiveAccessDraft,includeRecording:event.target.checked})} />同时申请本次通话原录音播放</label></fieldset><label><span>有效期</span><select value={sensitiveAccessDraft.validMinutes} onChange={(event)=>setSensitiveAccessDraft({...sensitiveAccessDraft,validMinutes:Number(event.target.value) as SensitiveAccessDraft["validMinutes"]})}><option value={15}>15分钟</option><option value={60}>1小时</option><option value={240}>4小时(上限)</option></select></label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={()=>setSensitiveAccessDraft(undefined)}>取消</button><button type="submit" disabled={busy||!sensitiveAccessDraft.reason.trim()}>{busy?"正在提交…":"提交审批"}</button></div></form>:null}
|
||||
<div className="diagnostic-jobs integration-issues"><div className="section-heading"><div><p className="eyebrow">ISSUE LEDGER</p><h3>联调问题清单</h3><p>严重级别、根因、修复版本与脱敏回归证据形成可审计的外部验收闭环。</p></div><div className="row-actions"><select aria-label="问题状态筛选" value={issueStatus} onChange={(event)=>setIssueStatus(event.target.value as typeof issueStatus)}><option value="">全部状态</option><option value="OPEN">待处理</option><option value="IN_PROGRESS">处理中</option><option value="RESOLVED">待回归</option><option value="VERIFIED">已验证</option><option value="CLOSED">已关闭</option></select><button type="button" className="button-secondary" disabled={busy} onClick={()=>void loadIssues()}>刷新清单</button></div></div>
|
||||
<div className="issue-summary-grid"><article><span>全部问题</span><strong>{issueSummary.total}</strong><small>未关闭 {issueSummary.open}</small></article><article><span>严重问题</span><strong>{issueSummary.critical_open}</strong><small>CRITICAL 未关闭</small></article><article><span>高优先问题</span><strong>{issueSummary.high_open}</strong><small>HIGH 未关闭</small></article><article className={issueSummary.release_gate_satisfied?"issue-gate-pass":"issue-gate-blocked"}><span>高严重度门禁</span><strong>{issueSummary.release_gate_satisfied?"通过":"阻断"}</strong><small>{issueSummary.release_blocking} 项未关闭</small></article></div>
|
||||
{canOperate?<form className="integration-issue-form" onSubmit={(event)=>void createIssue(event)}><label><span>问题现象</span><textarea required maxLength={2000} value={issueDraft.phenomenon} onChange={(event)=>setIssueDraft({...issueDraft,phenomenon:event.target.value})} placeholder="描述可复现现象,不粘贴字幕、录音地址或密钥" /></label><label><span>严重级别</span><select value={issueDraft.severity} onChange={(event)=>setIssueDraft({...issueDraft,severity:event.target.value as IntegrationIssue["severity"]})}><option value="LOW">LOW · 低</option><option value="MEDIUM">MEDIUM · 中</option><option value="HIGH">HIGH · 高</option><option value="CRITICAL">CRITICAL · 严重</option></select></label><label><span>责任方</span><input required maxLength={120} value={issueDraft.responsible_party} onChange={(event)=>setIssueDraft({...issueDraft,responsible_party:event.target.value})} placeholder="如:硬件团队、Agent研发组" /></label><label><span>发生时间</span><input required type="datetime-local" value={issueDraft.occurred_at} onChange={(event)=>setIssueDraft({...issueDraft,occurred_at:event.target.value})} /></label><label><span>设备编号(可选)</span><input value={issueDraft.device_id} onChange={(event)=>setIssueDraft({...issueDraft,device_id:event.target.value})} /></label><label><span>通话编号(可选)</span><input value={issueDraft.conversation_id} onChange={(event)=>setIssueDraft({...issueDraft,conversation_id:event.target.value})} /></label><label><span>请求编号(可选)</span><input maxLength={128} value={issueDraft.request_id} onChange={(event)=>setIssueDraft({...issueDraft,request_id:event.target.value})} /></label><button type="submit" disabled={busy||!issueDraft.phenomenon.trim()||!issueDraft.responsible_party.trim()}>登记问题</button></form>:null}
|
||||
{!issues.length?<p className="empty-copy">当前筛选条件下没有问题记录。</p>:issues.map((issue)=><article className="diagnostic-card integration-issue-card" key={issue.id}><div className="diagnostic-card__head"><div><strong>{issue.phenomenon}</strong><span>{new Date(issue.occurred_at).toLocaleString()} · 责任方 {issue.responsible_party}</span></div><div className="row-actions"><span className={`status ${integrationIssueSeverityTone(issue.severity)}`}>{issue.severity}</span><span className={`status ${integrationIssueTone(issue.status)}`}>{integrationIssueLabel(issue.status)}</span></div></div><dl><div><dt>设备编号</dt><dd>{issue.device_id||"—"}</dd></div><div><dt>通话编号</dt><dd>{issue.conversation_id||"—"}</dd></div><div><dt>请求编号</dt><dd>{issue.request_id||"—"}</dd></div><div><dt>修复版本</dt><dd>{issue.fixed_version||"—"}</dd></div></dl>{issue.root_cause?<p><strong>根因:</strong>{issue.root_cause}</p>:null}{issue.resolution_result?<p><strong>解决结果:</strong>{issue.resolution_result}</p>:null}{issue.regression_evidence?<p><strong>回归证据:</strong>{issue.regression_evidence}</p>:null}{issue.regression_conclusion?<p><strong>回归结论:</strong>{issue.regression_conclusion}</p>:null}{canOperate?<div className="row-actions"><button type="button" className="button-secondary" disabled={busy} onClick={()=>openIssueAction(issue,"severity")}>调整级别</button>{issue.status==="OPEN"?<button type="button" disabled={busy} onClick={()=>openIssueAction(issue,"take")}>接手处理</button>:null}{issue.status==="IN_PROGRESS"?<button type="button" disabled={busy} onClick={()=>openIssueAction(issue,"resolve")}>登记解决</button>:null}{issue.status==="RESOLVED"?<button type="button" disabled={busy} onClick={()=>openIssueAction(issue,"verify")}>登记回归</button>:null}{issue.status==="VERIFIED"?<button type="button" disabled={busy} onClick={()=>openIssueAction(issue,"close")}>关闭问题</button>:null}{["RESOLVED","VERIFIED","CLOSED"].includes(issue.status)?<button type="button" className="button-secondary" disabled={busy} onClick={()=>openIssueAction(issue,"reopen")}>重新打开</button>:null}</div>:null}</article>)}</div>
|
||||
{issueActionDraft?<form className={`sensitive-access-request controlled-operation${issueActionDraft.action==="close"?" controlled-operation--danger":""}`} aria-label="联调问题流转编辑" onSubmit={(event)=>void updateIssue(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">ISSUE WORKFLOW</p><h3>{issueActionLabel(issueActionDraft.action)}</h3></div><span className={`status ${issueActionDraft.action==="close"?"danger":"warning"}`}>ETag版本 {issueActionDraft.item.version}</span></div><p>问题 <strong>{issueActionDraft.item.id}</strong> · 当前状态 <strong>{integrationIssueLabel(issueActionDraft.item.status)}</strong> · 当前责任方 <strong>{issueActionDraft.item.responsible_party}</strong><br />{issueActionDraft.item.phenomenon}</p>{issueActionDraft.action==="severity"?<label><span>严重级别</span><select autoFocus value={issueActionDraft.severity} onChange={(event)=>setIssueActionDraft({...issueActionDraft,severity:event.target.value as IntegrationIssue["severity"]})}><option value="LOW">LOW · 低</option><option value="MEDIUM">MEDIUM · 中</option><option value="HIGH">HIGH · 高</option><option value="CRITICAL">CRITICAL · 严重</option></select><small>必须选择与当前级别不同的值;HIGH和CRITICAL在关闭前持续阻断发布门禁。</small></label>:null}{issueActionDraft.action==="take"||issueActionDraft.action==="reopen"?<label><span>{issueActionDraft.action==="take"?"接手责任方":"重新处理责任方"}</span><input autoFocus required maxLength={120} value={issueActionDraft.responsibleParty} onChange={(event)=>setIssueActionDraft({...issueActionDraft,responsibleParty:event.target.value})} placeholder="如:硬件团队、Agent研发组" /></label>:null}{issueActionDraft.action==="resolve"?<><label><span>根因结论</span><textarea autoFocus required maxLength={4000} value={issueActionDraft.rootCause} onChange={(event)=>setIssueActionDraft({...issueActionDraft,rootCause:event.target.value})} placeholder="描述已定位的根因,不粘贴字幕、录音、凭据或签名地址" /></label><label><span>修复版本或不可变构建标识</span><input required maxLength={128} value={issueActionDraft.fixedVersion} onChange={(event)=>setIssueActionDraft({...issueActionDraft,fixedVersion:event.target.value})} /></label><label><span>解决结果</span><textarea required maxLength={4000} value={issueActionDraft.resolutionResult} onChange={(event)=>setIssueActionDraft({...issueActionDraft,resolutionResult:event.target.value})} /></label></>:null}{issueActionDraft.action==="verify"?<><label><span>脱敏回归证据</span><textarea autoFocus required maxLength={4000} value={issueActionDraft.regressionEvidence} onChange={(event)=>setIssueActionDraft({...issueActionDraft,regressionEvidence:event.target.value})} placeholder="填写请求关联号、诊断包编号、重复次数等可复核证据" /></label><label><span>回归结论</span><textarea required maxLength={4000} value={issueActionDraft.regressionConclusion} onChange={(event)=>setIssueActionDraft({...issueActionDraft,regressionConclusion:event.target.value})} placeholder="说明验证范围、复现次数和最终结论" /></label></>:null}{issueActionDraft.action==="close"?<label className="controlled-operation__confirmation"><input autoFocus type="checkbox" checked={issueActionDraft.acknowledged} onChange={(event)=>setIssueActionDraft({...issueActionDraft,acknowledged:event.target.checked})} />我已复核根因、修复版本、解决结果及回归证据,同意关闭该问题</label>:null}<div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={()=>setIssueActionDraft(undefined)}>取消</button><button type="submit" className={issueActionDraft.action==="close"?"button-danger":undefined} disabled={busy||!issueActionReady(issueActionDraft)}>{busy?"正在更新…":issueActionSubmitLabel(issueActionDraft.action)}</button></div></form>:null}
|
||||
<div className="diagnostic-jobs"><div className="section-heading"><div><p className="eyebrow">TEMPORARY ACCESS</p><h3>限时敏感数据访问</h3></div><button type="button" className="button-secondary" disabled={busy} onClick={()=>void loadAccessRequests()}>刷新申请</button></div>{!accessRequests.length?<p className="empty-copy">申请人只能看到自己的记录;审批角色可查看当前客户组织的待审批队列。</p>:accessRequests.map((item)=><article className="diagnostic-card" key={item.id}><div className="diagnostic-card__head"><div><strong>{item.conversation_id}</strong><span>{item.capabilities.map(sensitiveCapabilityLabel).join(" + ")} · 至 {new Date(item.requested_expires_at).toLocaleString()}</span></div><span className={`status ${item.status==="APPROVED"?"active":item.status==="PENDING"?"warning":"neutral"}`}>{sensitiveAccessStatusLabel(item.status)}</span></div><p>{item.reason}</p><div className="row-actions">{canApprove&&item.status==="PENDING"&&item.requester_user_id!==account?.user_id?<><button type="button" disabled={busy} onClick={()=>openSensitiveAccessReview(item,"APPROVE")}>批准</button><button type="button" className="button-danger" disabled={busy} onClick={()=>openSensitiveAccessReview(item,"REJECT")}>拒绝</button></>:null}{item.status==="APPROVED"&&item.requester_user_id===account?.user_id?<button type="button" disabled={busy} onClick={()=>void readSensitive(item)}>按批准范围查看</button>:null}</div></article>)}</div>
|
||||
{sensitiveAccessReviewDraft?<form className={`sensitive-access-request controlled-operation${sensitiveAccessReviewDraft.decision==="APPROVE"?" controlled-operation--danger":""}`} aria-label="临时敏感访问审批" onSubmit={(event)=>void reviewAccess(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">TEMPORARY ACCESS REVIEW</p><h3>{sensitiveAccessReviewDraft.decision==="APPROVE"?"批准临时敏感访问":"拒绝临时敏感访问"}</h3></div><span className={`status ${sensitiveAccessReviewDraft.decision==="APPROVE"?"danger":"warning"}`}>{sensitiveAccessReviewDraft.decision==="APPROVE"?"授予限时能力":"终止本次申请"}</span></div><p>申请 <strong>{sensitiveAccessReviewDraft.item.id}</strong> · 版本 <strong>{sensitiveAccessReviewDraft.item.version}</strong><br />申请人 <strong>{sensitiveAccessReviewDraft.item.requester_user_id}</strong> · 通话 <strong>{sensitiveAccessReviewDraft.item.conversation_id}</strong><br />能力 <strong>{sensitiveAccessReviewDraft.item.capabilities.map(sensitiveCapabilityLabel).join(" + ")}</strong> · 到期 <strong>{new Date(sensitiveAccessReviewDraft.item.requested_expires_at).toLocaleString()}</strong><br />申请理由:{sensitiveAccessReviewDraft.item.reason}</p><label><span>{sensitiveAccessReviewDraft.decision==="APPROVE"?"批准依据":"拒绝原因"}</span><textarea autoFocus required maxLength={500} value={sensitiveAccessReviewDraft.reason} onChange={(event)=>setSensitiveAccessReviewDraft({...sensitiveAccessReviewDraft,reason:event.target.value})} placeholder={sensitiveAccessReviewDraft.decision==="APPROVE"?"说明已复核的问题范围、最小能力和有效期":"说明申请范围、能力、理由或有效期不满足要求的具体原因"} /></label>{sensitiveAccessReviewDraft.decision==="APPROVE"?<label className="controlled-operation__confirmation"><input type="checkbox" checked={sensitiveAccessReviewDraft.acknowledged} onChange={(event)=>setSensitiveAccessReviewDraft({...sensitiveAccessReviewDraft,acknowledged:event.target.checked})} />我确认申请人不是本人,已复核单次通话、最小能力和到期时间,批准后只有原申请人可在有效期内使用</label>:null}<div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={()=>setSensitiveAccessReviewDraft(undefined)}>取消</button><button type="submit" className={sensitiveAccessReviewDraft.decision==="REJECT"?"button-danger":undefined} disabled={busy||!sensitiveAccessReviewDraft.reason.trim()||(sensitiveAccessReviewDraft.decision==="APPROVE"&&!sensitiveAccessReviewDraft.acknowledged)}>{busy?"正在提交…":sensitiveAccessReviewDraft.decision==="APPROVE"?"确认批准":"确认拒绝"}</button></div></form>:null}
|
||||
{sensitive?<div className="diagnostic-jobs"><div className="section-heading"><div><p className="eyebrow">AUDITED CONTENT</p><h3>本次限时读取</h3></div><span className="status warning">至 {new Date(sensitive.access_until).toLocaleTimeString()}</span></div><article className="diagnostic-card"><p>通话 {sensitive.conversation_id} · 每次读取和播放均独立审计</p>{sensitive.turns.map((turn)=><p key={turn.id}><strong>{turn.speaker==="subject"?"长者":"智能体"}:</strong>{turn.final_text}</p>)}{sensitive.segments.length?<div className="row-actions">{sensitive.segments.map((segment)=><button type="button" key={segment.id} disabled={busy} onClick={()=>void playSensitive(segment.id)}>播放 {segment.track} #{segment.sequence_no}</button>)}</div>:null}</article></div>:null}
|
||||
<div className="diagnostic-jobs"><div className="section-heading"><div><p className="eyebrow">RELIABLE JOBS</p><h3>死信任务</h3></div><button type="button" className="button-secondary" disabled={busy} onClick={()=>void loadJobs()}>刷新任务</button></div>{!jobs.length?<p className="empty-copy">当前未加载死信任务,或客户组织没有待人工重试任务。</p>:jobs.map((job)=><article className="diagnostic-card" key={job.id}><div className="diagnostic-card__head"><div><strong>{job.event_type}</strong><span>{job.aggregate_type} · 自动尝试 {job.attempt_count} 次 · 人工重试 {job.manual_retry_count} 次</span></div><span className="status danger">{job.status}</span></div><p>归一化错误:{job.last_error_code||"未记录"}</p>{canOperate?<div className="row-actions"><button type="button" disabled={busy} onClick={()=>openJobRetry(job)}>受控重试</button></div>:null}</article>)}</div>
|
||||
{diagnosticJobRetryDraft?<form className="sensitive-access-request controlled-operation controlled-operation--danger" aria-label="死信任务受控重试" onSubmit={(event)=>void retryJob(event)}><div className="sensitive-access-request__head"><div><p className="eyebrow">RELIABLE JOB RECOVERY</p><h3>确认重试死信任务</h3></div><span className="status danger">重新启用执行预算</span></div><p>任务 <strong>{diagnosticJobRetryDraft.job.id}</strong> · 类型 <strong>{diagnosticJobRetryDraft.job.event_type}</strong> · 聚合类型 <strong>{diagnosticJobRetryDraft.job.aggregate_type}</strong>。此前自动尝试 {diagnosticJobRetryDraft.job.attempt_count} 次,最近错误为 <strong>{diagnosticJobRetryDraft.job.last_error_code||"未记录"}</strong>。</p><label><span>重试原因</span><textarea autoFocus required maxLength={500} value={diagnosticJobRetryDraft.reason} onChange={(event)=>setDiagnosticJobRetryDraft({...diagnosticJobRetryDraft,reason:event.target.value})} placeholder="说明根因、依赖恢复证据和本次重试依据;不要粘贴任务正文或凭据" /></label><label className="controlled-operation__confirmation"><input type="checkbox" checked={diagnosticJobRetryDraft.acknowledged} onChange={(event)=>setDiagnosticJobRetryDraft({...diagnosticJobRetryDraft,acknowledged:event.target.checked})} />我已确认根因或依赖问题已经处理,并理解失败后任务仍可能再次进入死信</label><div className="sensitive-access-request__actions"><button type="button" className="button-secondary" disabled={busy} onClick={()=>setDiagnosticJobRetryDraft(undefined)}>取消</button><button type="submit" className="button-danger" disabled={busy||!diagnosticJobRetryDraft.reason.trim()||!diagnosticJobRetryDraft.acknowledged}>{busy?"正在重试…":"确认受控重试"}</button></div></form>:null}
|
||||
</section>
|
||||
}
|
||||
|
||||
function errorText(cause:unknown){return cause instanceof APIRequestError?`${cause.message}${cause.code?`(${cause.code})`:""}`:cause instanceof Error?cause.message:"未知错误"}
|
||||
function diagnosticControlLabel(kind:DiagnosticControlKind){return ({refresh:"刷新设备配置",reconcile:"执行状态对账",stop:"安全停止通话",revoke:"吊销设备身份"} as Record<DiagnosticControlKind,string>)[kind]}
|
||||
function diagnosticControlDescription(kind:DiagnosticControlKind){return ({refresh:"向该设备可靠下发一次配置刷新意图;设备实际应用结果仍以随后上报为准。",reconcile:"根据云端通话与供应商任务状态执行一次受控对账,不直接修改字幕或录音。",stop:"为当前活动通话创建可靠停止意图,设备和供应商状态将随后收敛。",revoke:"立即吊销该设备身份;后续设备签名请求会被拒绝,活动通话也会进入受控停止流程。"} as Record<DiagnosticControlKind,string>)[kind]}
|
||||
function diagnosticControlNeedsAcknowledgement(kind:DiagnosticControlKind){return kind==="stop"||kind==="revoke"}
|
||||
function diagnosticFieldLabel(field:DiagnosticBundleDraft["field"]){return fields.find(([key])=>key===field)?.[1]??field}
|
||||
function issueActionLabel(action:IssueAction){return ({severity:"调整问题严重级别",take:"接手处理问题",resolve:"登记问题解决",verify:"登记回归验证",close:"关闭联调问题",reopen:"重新打开问题"} as Record<IssueAction,string>)[action]}
|
||||
function issueActionSubmitLabel(action:IssueAction){return ({severity:"保存级别",take:"确认接手",resolve:"提交解决记录",verify:"提交回归记录",close:"确认关闭",reopen:"重新打开"} as Record<IssueAction,string>)[action]}
|
||||
function issueActionReady(draft:IssueActionDraft){if(draft.action==="severity")return draft.severity!==draft.item.severity;if(draft.action==="take"||draft.action==="reopen")return Boolean(draft.responsibleParty.trim());if(draft.action==="resolve")return Boolean(draft.rootCause.trim()&&draft.fixedVersion.trim()&&draft.resolutionResult.trim());if(draft.action==="verify")return Boolean(draft.regressionEvidence.trim()&&draft.regressionConclusion.trim());return draft.acknowledged}
|
||||
function sensitiveCapabilityLabel(value:OpsSensitiveAccessRequest["capabilities"][number]){return value==="TRANSCRIPT_READ"?"读取最终字幕":"播放原录音"}
|
||||
function sensitiveAccessStatusLabel(value:OpsSensitiveAccessRequest["status"]){return ({PENDING:"待审批",APPROVED:"已批准",REJECTED:"已拒绝",REVOKED:"已撤销",EXPIRED:"已到期"} as Record<OpsSensitiveAccessRequest["status"],string>)[value]}
|
||||
function formatDuration(value:number|undefined){if(value===undefined)return "—";if(value<1000)return `${value}毫秒`;if(value<60000)return `${(value/1000).toFixed(value<10000?2:1)}秒`;return `${Math.floor(value/60000)}分${Math.round((value%60000)/1000)}秒`}
|
||||
function formatTimestamp(value:string|undefined){return value?new Date(value).toLocaleString("zh-CN",{hour12:false}):"—"}
|
||||
function diagnosticFailureLabel(value:string){return value==="context_preparation_failed"?"背景资料准备失败,未进入语音通话":"通话失败"}
|
||||
function localDateTimeInput(){const now=new Date();return new Date(now.getTime()-now.getTimezoneOffset()*60000).toISOString().slice(0,16)}
|
||||
function localDateInput(){const now=new Date();return new Date(now.getTime()-now.getTimezoneOffset()*60000).toISOString().slice(0,10)}
|
||||
function localWeekStart(){const now=new Date();const local=new Date(now.getTime()-now.getTimezoneOffset()*60000);const weekday=(local.getUTCDay()+6)%7;local.setUTCDate(local.getUTCDate()-weekday);return local.toISOString().slice(0,10)}
|
||||
function localTimezone(){return Intl.DateTimeFormat().resolvedOptions().timeZone||"Asia/Shanghai"}
|
||||
function pilotStatusCount(snapshot:OpsPilotDailySnapshot,status:OpsPilotDailySnapshot["conversations"]["statuses"][number]["status"]){return snapshot.conversations.statuses.find((item)=>item.status===status)?.count??0}
|
||||
function basisPointsLabel(available:boolean,value:number){return available?`${(value/100).toFixed(2)}%`:"—"}
|
||||
function integrationIssueLabel(value:IntegrationIssue["status"]){return ({OPEN:"待处理",IN_PROGRESS:"处理中",RESOLVED:"待回归",VERIFIED:"已验证",CLOSED:"已关闭"} as Record<IntegrationIssue["status"],string>)[value]}
|
||||
function integrationIssueTone(value:IntegrationIssue["status"]){return value==="OPEN"?"danger":value==="IN_PROGRESS"||value==="RESOLVED"?"warning":value==="VERIFIED"?"active":"neutral"}
|
||||
function integrationIssueSeverityTone(value:IntegrationIssue["severity"]){return value==="CRITICAL"?"danger":value==="HIGH"?"warning":value==="LOW"?"neutral":"active"}
|
||||
function externalDependencyLabel(value:OpsExternalDependency["dependency"]){return ({oidc_jwks:"OIDC签名密钥",ark_tls:"方舟模型服务TLS",volc_rtc_tls:"火山RTC服务TLS",content_safety_tls:"内容安全策略服务TLS"} as Record<OpsExternalDependency["dependency"],string>)[value]}
|
||||
function serviceLabel(value:OpsExternalDependency["service_name"]){return ({api:"API进程",worker:"Worker进程",callbacks:"回调进程"} as Record<OpsExternalDependency["service_name"],string>)[value]}
|
||||
function externalDependencyState(item:OpsExternalDependency){const stale=Date.now()-new Date(item.checked_at).getTime()>item.probe_interval_ms*3;return stale?{label:"结果过期",tone:"warning",stale:true}:item.status==="AVAILABLE"?{label:"可用",tone:"active",stale:false}:{label:"不可用",tone:"danger",stale:false}}
|
||||
function downloadJSON(value:unknown,filename:string){const blob=new Blob([JSON.stringify(value,null,2)],{type:"application/json"});const url=URL.createObjectURL(blob);const anchor=document.createElement("a");anchor.href=url;anchor.download=filename;anchor.click();URL.revokeObjectURL(url)}
|
||||
function acceptanceGateLabel(value:OpsAcceptanceSnapshot["gates"][number]["code"]){return ({candidate_build_identified:"候选构建",database_migration_clean:"数据库迁移",required_dependencies_available:"必需外部依赖",integration_issues_clear:"高严重度问题",outbox_clear:"可靠任务",critical_alerts_clear:"高严重度告警"} as Record<OpsAcceptanceSnapshot["gates"][number]["code"],string>)[value]}
|
||||
function acceptanceResourceStateLabel(value:OpsAcceptanceSnapshot["alerts"]["blocking_items"][number]["resource_state"]){return ({PRESENT:"资源存在",MISSING:"资源已不存在,仍需人工复核关闭",AGGREGATE:"聚合队列",UNKNOWN:"未提供自动核对规则"} as Record<OpsAcceptanceSnapshot["alerts"]["blocking_items"][number]["resource_state"],string>)[value]}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { platformApi } from "../api/client";
|
||||
import { StatusCard } from "../components/StatusCard";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { SaaSWorkspace } from "./SaaSWorkspace";
|
||||
import { OpsConfigWorkspace } from "./OpsConfigWorkspace";
|
||||
import { OpsBillingWorkspace } from "./OpsBillingWorkspace";
|
||||
import { OpsAlertWorkspace } from "./OpsAlertWorkspace";
|
||||
import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace";
|
||||
|
||||
type ViewState = {
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
live?: string;
|
||||
service?: string;
|
||||
ready?: string;
|
||||
checks?: Record<string, string>;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type WorkspaceKey = "family" | "config" | "billing" | "alerts" | "diagnostics";
|
||||
const workspaceKeys: WorkspaceKey[] = ["family", "config", "billing", "alerts", "diagnostics"];
|
||||
|
||||
export function PlatformOverview() {
|
||||
const [state, setState] = useState<ViewState>({ loading: true });
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>(() => workspaceFromURL());
|
||||
const auth = useAuth();
|
||||
|
||||
const refresh = useCallback(async (signal?: AbortSignal) => {
|
||||
setState((current) => ({ ...current, loading: true, error: undefined }));
|
||||
try {
|
||||
const [live, ready, version] = await Promise.all([
|
||||
platformApi.liveness(signal),
|
||||
platformApi.readiness(signal),
|
||||
platformApi.version(signal)
|
||||
]);
|
||||
setState({
|
||||
loading: false,
|
||||
live: live.data.status,
|
||||
service: live.data.service,
|
||||
ready: ready.data.status,
|
||||
checks: ready.data.checks,
|
||||
version: version.data.version
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
setState({ loading: false, error: error instanceof Error ? error.message : "未知错误" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void refresh(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [refresh]);
|
||||
|
||||
const dependencySummary = state.checks
|
||||
? Object.entries(state.checks)
|
||||
.map(([name, status]) => `${name}: ${status}`)
|
||||
.join(" · ")
|
||||
: "等待依赖检查";
|
||||
|
||||
const hasOperationsAccess = Boolean(auth.currentUser?.accounts.some((account) => account.platform_role !== "customer"));
|
||||
const workspaceOptions: { key: WorkspaceKey; label: string; detail: string }[] = [
|
||||
{ key: "family", label: "家庭业务", detail: "长者、授权、设备、通话与内容" },
|
||||
...(hasOperationsAccess ? [
|
||||
{ key: "config" as const, label: "运行配置", detail: "配置版本、评测、资源与灰度" },
|
||||
{ key: "billing" as const, label: "费用额度", detail: "价格、用量、费用与硬额度" },
|
||||
{ key: "alerts" as const, label: "告警处置", detail: "告警队列、路由、静默与时间线" },
|
||||
{ key: "diagnostics" as const, label: "联调诊断", detail: "设备、通话、作业与临时提权" }
|
||||
] : [])
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setActiveWorkspace(workspaceFromURL());
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.loading || !auth.currentUser) return;
|
||||
if (!hasOperationsAccess && activeWorkspace !== "family") {
|
||||
setActiveWorkspace("family");
|
||||
replaceWorkspaceURL("family");
|
||||
return;
|
||||
}
|
||||
const requested = new URL(window.location.href).searchParams.get("workspace");
|
||||
const expected = activeWorkspace === "family" ? null : activeWorkspace;
|
||||
if (requested !== expected) replaceWorkspaceURL(activeWorkspace);
|
||||
}, [activeWorkspace, auth.currentUser, auth.loading, hasOperationsAccess]);
|
||||
|
||||
function selectWorkspace(workspace: WorkspaceKey) {
|
||||
if (workspace === activeWorkspace) return;
|
||||
setActiveWorkspace(workspace);
|
||||
pushWorkspaceURL(workspace);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">DATA TERMINAL · CLOUD PLATFORM</p>
|
||||
<h1>让每一次陪伴,都安全抵达</h1>
|
||||
<p className="hero__description">
|
||||
数据采集终端—云端系统研发工作台。当前版本已经接入家庭、长者、授权、设备与通话业务链路。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void refresh()} disabled={state.loading}>
|
||||
{state.loading ? "检查中…" : "重新检查"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{state.error ? (
|
||||
<section className="notice notice--error" role="alert">
|
||||
<div>
|
||||
<strong>暂时无法连接云端服务</strong>
|
||||
<p>{state.error}。请确认API、PostgreSQL和Redis已经启动。</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void refresh()}>重试</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="status-grid" aria-label="平台状态">
|
||||
<StatusCard
|
||||
label="API进程"
|
||||
value={state.loading ? "检查中" : state.live === "alive" ? "运行正常" : "等待启动"}
|
||||
detail={state.service ? `当前服务:${state.service}` : "尚未获得服务信息"}
|
||||
tone={state.live === "alive" ? "good" : "neutral"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="必要依赖"
|
||||
value={state.loading ? "检查中" : state.ready === "ready" ? "全部就绪" : "需要处理"}
|
||||
detail={dependencySummary}
|
||||
tone={state.ready === "ready" ? "good" : state.loading ? "neutral" : "warning"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="构建版本"
|
||||
value={state.version ?? "dev"}
|
||||
detail="版本、提交和构建时间将在流水线中自动注入"
|
||||
tone="neutral"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="identity-panel" aria-label="登录和权限状态">
|
||||
<div className="identity-panel__intro">
|
||||
<p className="eyebrow">IDENTITY & ACCESS</p>
|
||||
<h2>身份与家庭权限</h2>
|
||||
<p>OIDC只证明登录身份,客户组织与家庭权限全部由服务端关系推导。</p>
|
||||
</div>
|
||||
{auth.loading ? (
|
||||
<div className="identity-panel__state">正在读取登录状态…</div>
|
||||
) : auth.currentUser ? (
|
||||
<div className="identity-panel__account">
|
||||
<div className="identity-panel__account-head">
|
||||
<div>
|
||||
<strong>{auth.currentUser.identity.display_name || auth.currentUser.identity.preferred_username}</strong>
|
||||
<span>{auth.currentUser.identity.preferred_username}</span>
|
||||
</div>
|
||||
<button type="button" className="button-secondary" onClick={() => void auth.logout()}>退出登录</button>
|
||||
</div>
|
||||
{auth.currentUser.accounts.map((account) => (
|
||||
<article className="account-card" key={account.user_id}>
|
||||
<p>{account.tenant_name}</p>
|
||||
<strong>{account.display_name}</strong>
|
||||
{account.household_memberships.map((membership) => (
|
||||
<div className="membership" key={membership.household_id}>
|
||||
<span>{membership.household_name}</span>
|
||||
<span>{roleLabel(membership.role)} · {membership.capabilities.length}项一般权限</span>
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="identity-panel__login">
|
||||
<strong>{auth.error ? "登录信息读取失败" : "尚未登录"}</strong>
|
||||
<p>{auth.error ?? "进入本地Keycloak完成授权码与PKCE登录。研发账号见权限矩阵文档。"}</p>
|
||||
<div>
|
||||
{auth.error && auth.oidcUser ? (
|
||||
<button type="button" className="button-secondary" onClick={() => void auth.retry()}>重新读取</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => void auth.login()}>{auth.error ? "重新登录" : "登录研发环境"}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{auth.currentUser && auth.oidcUser ? (
|
||||
<section className="workspace-shell" aria-label="业务工作台">
|
||||
<div className="workspace-shell__heading">
|
||||
<div>
|
||||
<p className="eyebrow">BUSINESS WORKSPACES</p>
|
||||
<h2>业务工作台</h2>
|
||||
</div>
|
||||
<p>根据当前账号权限显示可用入口;切换入口时才加载对应业务数据。</p>
|
||||
</div>
|
||||
<nav className="workspace-nav" aria-label="业务工作台导航">
|
||||
{workspaceOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.key}
|
||||
className={activeWorkspace === option.key ? "workspace-nav__item active" : "workspace-nav__item"}
|
||||
aria-current={activeWorkspace === option.key ? "page" : undefined}
|
||||
onClick={() => selectWorkspace(option.key)}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.detail}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="workspace-shell__content">
|
||||
{activeWorkspace === "family" ? <SaaSWorkspace accessToken={auth.oidcUser.access_token} accounts={auth.currentUser.accounts} /> : null}
|
||||
{activeWorkspace === "config" && hasOperationsAccess ? <OpsConfigWorkspace accessToken={auth.oidcUser.access_token} accounts={auth.currentUser.accounts} /> : null}
|
||||
{activeWorkspace === "billing" && hasOperationsAccess ? <OpsBillingWorkspace accessToken={auth.oidcUser.access_token} accounts={auth.currentUser.accounts} /> : null}
|
||||
{activeWorkspace === "alerts" && hasOperationsAccess ? <OpsAlertWorkspace accessToken={auth.oidcUser.access_token} accounts={auth.currentUser.accounts} /> : null}
|
||||
{activeWorkspace === "diagnostics" && hasOperationsAccess ? <OpsDiagnosticsWorkspace accessToken={auth.oidcUser.access_token} accounts={auth.currentUser.accounts} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="milestone-panel">
|
||||
<div>
|
||||
<p className="eyebrow">CURRENT MILESTONE</p>
|
||||
<h2>M8 · 联调、试点与上线准备</h2>
|
||||
</div>
|
||||
<ol>
|
||||
<li className="done"><span>M0</span>需求与架构基线</li>
|
||||
<li className="done"><span>M1</span>工程与环境</li>
|
||||
<li className="done"><span>M2</span>公共业务底座</li>
|
||||
<li className="done"><span>M3</span>端到端语音闭环</li>
|
||||
<li className="done"><span>M4—M6</span>SaaS与数据闭环</li>
|
||||
<li className="done"><span>M7</span>运营与生产加固</li>
|
||||
<li className="active"><span>M8</span>联调、试点与上线</li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function roleLabel(role: string) {
|
||||
return ({ owner: "所有者", admin: "管理员", editor: "编辑者", viewer: "查看者" } as Record<string, string>)[role] ?? role;
|
||||
}
|
||||
|
||||
function workspaceFromURL(): WorkspaceKey {
|
||||
const requested = new URL(window.location.href).searchParams.get("workspace");
|
||||
return workspaceKeys.includes(requested as WorkspaceKey) ? requested as WorkspaceKey : "family";
|
||||
}
|
||||
|
||||
function workspaceURL(workspace: WorkspaceKey) {
|
||||
const url = new URL(window.location.href);
|
||||
if (workspace === "family") url.searchParams.delete("workspace");
|
||||
else url.searchParams.set("workspace", workspace);
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function pushWorkspaceURL(workspace: WorkspaceKey) {
|
||||
window.history.pushState({ workspace }, "", workspaceURL(workspace));
|
||||
}
|
||||
|
||||
function replaceWorkspaceURL(workspace: WorkspaceKey) {
|
||||
window.history.replaceState({ workspace }, "", workspaceURL(workspace));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__VOICE_PLATFORM_CONFIG__?: {
|
||||
oidcAuthority?: string;
|
||||
oidcClientId?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function configuredValue(value: string | undefined, fallback: string): string {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : fallback;
|
||||
}
|
||||
|
||||
export const runtimeConfig = Object.freeze({
|
||||
oidcAuthority: configuredValue(window.__VOICE_PLATFORM_CONFIG__?.oidcAuthority, "http://localhost:58180/realms/voice-local").replace(/\/+$/, ""),
|
||||
oidcClientId: configuredValue(window.__VOICE_PLATFORM_CONFIG__?.oidcClientId, "voice-platform-web")
|
||||
});
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,893 @@
|
||||
:root {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #173042;
|
||||
background: #f2f6f4;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 85% 3%, rgba(242, 187, 90, 0.22), transparent 28rem),
|
||||
linear-gradient(180deg, #e7f0ed 0, #f5f7f4 28rem, #f2f6f4 100%);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0.8rem 1.25rem;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: #cb6d35;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
button:hover { transform: translateY(-1px); }
|
||||
button:disabled { cursor: wait; opacity: 0.65; }
|
||||
.button-secondary { color: #244356; background: #e5ece9; }
|
||||
|
||||
main {
|
||||
width: min(1120px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 3rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.75rem;
|
||||
color: #a4532b;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 { margin: 0; color: #10283a; }
|
||||
h1 { max-width: 13em; font-family: Georgia, "Songti SC", serif; font-size: clamp(2.4rem, 5vw, 4rem); line-height: 1.08; }
|
||||
h2 { font-size: clamp(1.5rem, 3vw, 2.2rem); }
|
||||
|
||||
.hero__description {
|
||||
max-width: 44rem;
|
||||
margin: 1.3rem 0 0;
|
||||
color: #506675;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.2rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid #e4bba4;
|
||||
border-radius: 1rem;
|
||||
background: #fff6f0;
|
||||
}
|
||||
|
||||
.notice p { margin: 0.3rem 0 0; color: #775644; }
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
min-height: 13rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(21, 53, 72, 0.09);
|
||||
border-radius: 1.4rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
box-shadow: 0 1rem 3rem rgba(24, 55, 66, 0.06);
|
||||
}
|
||||
|
||||
.status-card__topline { display: flex; justify-content: space-between; color: #6e8089; }
|
||||
.status-card__dot { width: 0.65rem; height: 0.65rem; border-radius: 50%; background: #82929a; }
|
||||
.status-card--good .status-card__dot { background: #4b9b72; box-shadow: 0 0 0 0.35rem rgba(75, 155, 114, 0.12); }
|
||||
.status-card--warning .status-card__dot { background: #d07942; box-shadow: 0 0 0 0.35rem rgba(208, 121, 66, 0.12); }
|
||||
.status-card strong { display: block; margin-top: 2.3rem; color: #163348; font-size: 1.65rem; }
|
||||
.status-card p { margin: 0.8rem 0 0; color: #647783; line-height: 1.6; }
|
||||
|
||||
.identity-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1.6fr;
|
||||
gap: 3rem;
|
||||
margin-top: 1rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid rgba(21, 53, 72, 0.09);
|
||||
border-radius: 1.4rem;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.identity-panel__intro > p:last-child { color: #647783; line-height: 1.7; }
|
||||
.identity-panel__state, .identity-panel__login { align-self: center; color: #647783; }
|
||||
.identity-panel__login strong { display: block; color: #183548; font-size: 1.25rem; }
|
||||
.identity-panel__login p { margin: 0.65rem 0 1.2rem; line-height: 1.6; }
|
||||
.identity-panel__login > div { display: flex; gap: 0.6rem; }
|
||||
.identity-panel__account { display: grid; gap: 0.8rem; }
|
||||
.identity-panel__account-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
.identity-panel__account-head strong, .identity-panel__account-head span { display: block; }
|
||||
.identity-panel__account-head span { margin-top: 0.25rem; color: #768791; font-size: 0.86rem; }
|
||||
.account-card { padding: 1rem; border-radius: 1rem; background: #edf3f0; }
|
||||
.account-card p { margin: 0 0 0.25rem; color: #687d87; font-size: 0.82rem; }
|
||||
.account-card > strong { color: #183548; }
|
||||
.membership { display: flex; justify-content: space-between; gap: 1rem; margin-top: 0.8rem; padding-top: 0.8rem; border-top: 1px solid #d7e2dd; color: #4e6672; font-size: 0.88rem; }
|
||||
|
||||
.workspace-shell {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(21, 53, 72, 0.09);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(228, 237, 233, 0.72);
|
||||
}
|
||||
|
||||
.workspace-shell__heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
padding: 0.8rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.workspace-shell__heading h2 { font-size: 1.45rem; }
|
||||
.workspace-shell__heading > p { max-width: 31rem; margin: 0; color: #627781; font-size: 0.82rem; line-height: 1.65; text-align: right; }
|
||||
|
||||
.workspace-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
gap: 0.55rem;
|
||||
padding: 0.55rem;
|
||||
border: 1px solid #d5e1dc;
|
||||
border-radius: 1.15rem;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.workspace-nav__item {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.85rem;
|
||||
color: #45616d;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.workspace-nav__item:hover { border-color: #d3dfda; background: #f4f8f6; }
|
||||
.workspace-nav__item.active { border-color: #c8ddd3; color: #fff; background: #245a4c; box-shadow: 0 0.65rem 1.5rem rgba(36, 90, 76, 0.16); }
|
||||
.workspace-nav__item strong { font-size: 0.9rem; }
|
||||
.workspace-nav__item span { overflow: hidden; color: #768a92; font-size: 0.68rem; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-nav__item.active span { color: #c9ded6; }
|
||||
.workspace-shell__content > .workspace, .workspace-shell__content > .ops-workspace { margin-top: 0.75rem; }
|
||||
|
||||
.milestone-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1.6fr;
|
||||
gap: 3rem;
|
||||
margin-top: 1rem;
|
||||
padding: 2rem;
|
||||
border-radius: 1.4rem;
|
||||
color: #fff;
|
||||
background: #10283a;
|
||||
}
|
||||
|
||||
.milestone-panel h2 { color: #fff; }
|
||||
.milestone-panel ol { display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.7rem; margin: 0; padding: 0; list-style: none; }
|
||||
.milestone-panel li { color: #8fa2ad; font-size: 0.85rem; line-height: 1.5; }
|
||||
.milestone-panel li span { display: block; margin-bottom: 1rem; color: #637985; font-size: 1.4rem; }
|
||||
.milestone-panel li.done, .milestone-panel li.active { color: #fff; }
|
||||
.milestone-panel li.done span { color: #75b690; }
|
||||
.milestone-panel li.active span { color: #efaf6d; }
|
||||
|
||||
.workspace {
|
||||
margin-top: 1rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid rgba(21, 53, 72, 0.09);
|
||||
border-radius: 1.4rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 1rem 3rem rgba(24, 55, 66, 0.06);
|
||||
}
|
||||
|
||||
.workspace__header { display: flex; align-items: end; justify-content: space-between; gap: 2rem; }
|
||||
.workspace__header > div:first-child > p:last-child { margin: 0.7rem 0 0; color: #647783; }
|
||||
.workspace__actions { display: flex; align-items: end; gap: 0.7rem; }
|
||||
.event-stream-status { display: inline-flex; align-items: center; align-self: center; gap: 0.35rem; padding: 0.35rem 0.6rem; border-radius: 999px; color: #5b7568; background: #e8f2ec; font-size: 0.68rem; font-weight: 800; white-space: nowrap; }
|
||||
.event-stream-status::before { width: 0.42rem; height: 0.42rem; border-radius: 50%; background: #4c9a70; content: ""; }
|
||||
.event-stream-status--connecting, .event-stream-status--reconnecting { color: #8a652d; background: #fff1da; }
|
||||
.event-stream-status--connecting::before, .event-stream-status--reconnecting::before { background: #d38a36; }
|
||||
.accept-invitation { display: flex; align-items: end; justify-content: flex-end; gap: 0.6rem; margin-top: 0.8rem; }
|
||||
.accept-invitation label { width: min(25rem, 100%); }
|
||||
.accept-invitation input, .invitation-secret input {
|
||||
width: 100%; box-sizing: border-box; border: 1px solid #ccd9d4; border-radius: 0.8rem;
|
||||
padding: 0.72rem 0.85rem; color: #183548; background: #fff; font: inherit;
|
||||
}
|
||||
.household-create { margin-top: 0.8rem; border: 1px solid #dbe6e1; border-radius: 1rem; background: #f8faf9; }
|
||||
.household-create summary { padding: 0.85rem 1rem; color: #355764; font-size: 0.8rem; font-weight: 800; cursor: pointer; }
|
||||
.household-create[open] summary { border-bottom: 1px solid #e2eae6; }
|
||||
.household-create .tool-form { border: 0; border-radius: 0 0 1rem 1rem; }
|
||||
.workspace label { display: grid; gap: 0.35rem; color: #6d7f88; font-size: 0.78rem; font-weight: 700; }
|
||||
.workspace select {
|
||||
min-width: 12rem;
|
||||
border: 1px solid #ccd9d4;
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.72rem 2rem 0.72rem 0.85rem;
|
||||
color: #183548;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.workspace__summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.8rem; margin-top: 1.5rem; }
|
||||
.conversation-readiness { display: grid; gap: 1rem; margin-top: 1rem; padding: 1.1rem; border: 1px solid #d9e5e0; border-radius: 1.1rem; background: linear-gradient(145deg,#f7faf8,#fff); }
|
||||
.conversation-readiness--ready { border-color: #bcd9ca; background: linear-gradient(145deg,#eef8f2,#fff); }
|
||||
.conversation-readiness--blocked { border-color: #ead5bd; background: linear-gradient(145deg,#fff8ef,#fff); }
|
||||
.conversation-readiness--loading { color: #6d8189; font-size: .76rem; }
|
||||
.conversation-readiness__head { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
|
||||
.conversation-readiness__head h3 { margin: .2rem 0; color: #294c59; }
|
||||
.conversation-readiness__head p { margin: 0; color: #6c8088; font-size: .75rem; }
|
||||
.readiness-state { flex: none; padding: .36rem .7rem; border-radius: 999px; font-size: .7rem; font-weight: 800; }
|
||||
.readiness-state--ready { color: #26694b; background: #dfeee5; }
|
||||
.readiness-state--blocked { color: #945426; background: #fae9d6; }
|
||||
.conversation-readiness__checks { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .55rem; }
|
||||
.readiness-check { display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: .65rem; min-width: 0; padding: .7rem .8rem; border: 1px solid #e2e9e6; border-radius: .78rem; background: rgba(255,255,255,.82); }
|
||||
.readiness-check__icon { display: grid; place-items: center; width: 1.55rem; height: 1.55rem; border-radius: 50%; color: #fff; background: #3a7d63; font-size: .72rem; font-weight: 900; }
|
||||
.readiness-check--blocked .readiness-check__icon { background: #b66d38; }
|
||||
.readiness-check--attention .readiness-check__icon { background: #8a7a39; }
|
||||
.readiness-check strong { display: block; color: #385864; font-size: .75rem; }
|
||||
.readiness-check p { margin: .16rem 0 0; color: #71848b; font-size: .68rem; line-height: 1.45; }
|
||||
.conversation-readiness__foot { margin: 0; color: #788990; font-size: .66rem; line-height: 1.55; }
|
||||
.story-source-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .5rem; }
|
||||
.metric { padding: 1rem; border-radius: 1rem; background: #edf3f0; }
|
||||
.metric > span { color: #6a7d86; font-size: 0.78rem; }
|
||||
.metric strong { display: block; margin-top: 0.35rem; color: #163348; font-size: 1.65rem; }
|
||||
.metric p { margin: 0.25rem 0 0; color: #72838b; font-size: 0.78rem; }
|
||||
|
||||
.workspace__tabs { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 1.5rem 0 1rem; padding: 0.3rem; border-radius: 1rem; background: #eef3f1; }
|
||||
.tab-button { border-radius: 0.75rem; padding: 0.7rem 1rem; color: #647983; background: transparent; }
|
||||
.tab-button.active { color: #fff; background: #244d57; }
|
||||
.tab-button:hover { transform: none; }
|
||||
.workspace__inline-error { margin: 0 0 1rem; padding: 0.8rem 1rem; border-radius: 0.8rem; color: #8d422e; background: #fff0ea; }
|
||||
.workspace__action-message { margin: 1rem 0 0; padding: 0.8rem 1rem; border-radius: 0.8rem; color: #276246; background: #e5f3ea; }
|
||||
.workspace__readonly-note { margin: 0 0 1rem; padding: 0.8rem 1rem; border-radius: 0.8rem; color: #667982; background: #f0f4f2; font-size: 0.82rem; }
|
||||
.workspace__tools { display: grid; gap: 0.7rem; margin-bottom: 1rem; }
|
||||
.tool-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(11rem, 1fr) minmax(10rem, 0.75fr) minmax(12rem, 1.25fr) auto;
|
||||
align-items: end;
|
||||
gap: 0.7rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid #dfe8e4;
|
||||
border-radius: 1rem;
|
||||
background: #f7f9f8;
|
||||
}
|
||||
.tool-form > div strong, .tool-form > div span { display: block; }
|
||||
.tool-form > div strong { color: #284754; font-size: 0.88rem; }
|
||||
.tool-form > div span { margin-top: 0.2rem; color: #778990; font-size: 0.72rem; line-height: 1.45; }
|
||||
.tool-form input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccd9d4;
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.72rem 0.85rem;
|
||||
color: #183548;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
.tool-form select { width: 100%; min-width: 0; }
|
||||
.tool-form__wide { min-width: 0; }
|
||||
.tool-form textarea, .profile-editor textarea {
|
||||
width: 100%; min-width: 0; box-sizing: border-box; border: 1px solid #ccd9d4; border-radius: 0.8rem;
|
||||
padding: 0.72rem 0.85rem; color: #183548; background: #fff; font: inherit; line-height: 1.5; resize: vertical;
|
||||
}
|
||||
.subject-create-form { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.subject-create-form > div:first-child { grid-column: 1 / -1; }
|
||||
.subject-create-form .tool-form__wide { grid-column: span 2; }
|
||||
.profile-editor { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: end; gap: 0.65rem; margin: 1rem 0; padding: 1rem; border: 1px solid #dfe8e4; border-radius: 1rem; background: #f7faf8; }
|
||||
.profile-editor .section-title { grid-column: 1 / -1; }
|
||||
.profile-editor .section-title > div p { margin: 0.2rem 0 0; color: #778990; font-size: 0.7rem; line-height: 1.45; }
|
||||
.profile-editor input, .profile-editor select { width: 100%; min-width: 0; box-sizing: border-box; }
|
||||
.profile-editor__wide { grid-column: 1 / -1; }
|
||||
.profile-editor > button { justify-self: end; }
|
||||
.consent-confirm { display: grid !important; grid-template-columns: auto 1fr; align-items: start; gap: 0.5rem !important; color: #566e79 !important; font-size: 0.72rem !important; font-weight: 500 !important; line-height: 1.45; }
|
||||
.consent-confirm input { width: auto; margin-top: 0.15rem; accent-color: #cb6d35; }
|
||||
.workspace__claim { margin-bottom: 1rem; }
|
||||
.row-actions { display: flex; flex: none; align-items: center; gap: 0.35rem; }
|
||||
.button-small { padding: 0.35rem 0.65rem; border-radius: 0.55rem; font-size: 0.7rem; }
|
||||
.button-danger { color: #934633; background: #fae8e3; }
|
||||
.status { display: inline-flex; align-items: center; min-height: 1.6rem; padding: 0.15rem 0.55rem; border-radius: 999px; color: #607580; background: #edf1ef; font-size: 0.68rem; font-weight: 800; white-space: nowrap; }
|
||||
.status.ready, .status.completed, .status.confirmed { color: #27704e; background: #e2f1e8; }
|
||||
.status.running, .status.uploading, .status.assigned, .status.downloading, .status.installing { color: #8a5a20; background: #fff0d8; }
|
||||
.status.paused, .status.failed, .status.retired, .status.rolled_back { color: #934633; background: #fae8e3; }
|
||||
.member-layout { display: grid; gap: 1rem; }
|
||||
.invitation-secret { display: grid; grid-template-columns: minmax(10rem, 0.8fr) minmax(14rem, 1.4fr) auto auto; align-items: center; gap: 0.6rem; padding: 0.9rem; border: 1px solid #e4bba4; border-radius: 1rem; background: #fff6f0; }
|
||||
.invitation-secret strong { color: #743e27; }
|
||||
.invitation-secret p { margin: 0.2rem 0 0; color: #8b6a58; font-size: 0.72rem; }
|
||||
.member-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
.member-columns > section { padding: 1rem; border: 1px solid #dfe8e4; border-radius: 1rem; }
|
||||
.member-row { display: flex; align-items: center; justify-content: space-between; gap: 0.7rem; padding: 0.75rem 0; border-bottom: 1px solid #edf1ef; }
|
||||
.member-row:last-child { border-bottom: 0; }
|
||||
.member-row strong { color: #284754; font-size: 0.86rem; }
|
||||
.member-row p { margin: 0.2rem 0 0; color: #77878e; font-size: 0.74rem; }
|
||||
.member-row__actions { display: flex; flex: none; align-items: center; gap: 0.35rem; }
|
||||
.member-row__actions select { min-width: 6.8rem; padding-top: 0.45rem; padding-bottom: 0.45rem; }
|
||||
|
||||
.workspace__split { display: grid; grid-template-columns: 15rem 1fr; min-height: 29rem; border: 1px solid #dfe8e4; border-radius: 1.1rem; overflow: hidden; }
|
||||
.resource-list { padding: 1rem; background: #f3f6f4; }
|
||||
.resource-list__title, .section-title, .conversation-filter { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
.resource-list__title { margin-bottom: 0.7rem; color: #526b76; }
|
||||
.resource-list__title span, .section-title span { color: #7a8c94; font-size: 0.78rem; }
|
||||
.resource-button, .conversation-row {
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.8rem;
|
||||
text-align: left;
|
||||
color: #34515f;
|
||||
background: transparent;
|
||||
}
|
||||
.resource-button:hover, .conversation-row:hover { transform: none; background: #e6eeea; }
|
||||
.resource-button.active, .conversation-row.active { color: #183548; background: #fff; box-shadow: 0 0.35rem 1rem rgba(24, 55, 66, 0.07); }
|
||||
.resource-button strong, .resource-button span { display: block; }
|
||||
.resource-button span { margin-top: 0.2rem; color: #7b8b92; font-size: 0.76rem; font-weight: 500; }
|
||||
.resource-detail { min-width: 0; padding: 1.4rem; }
|
||||
.resource-detail__head { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
|
||||
.resource-detail__head h3 { font-size: 1.65rem; }
|
||||
.resource-detail__head > div > p:last-child { margin: 0.4rem 0 0; color: #70828b; font-size: 0.84rem; }
|
||||
.permission { padding: 0.35rem 0.65rem; border-radius: 999px; color: #687c85; background: #edf1ef; font-size: 0.72rem; font-weight: 800; }
|
||||
.permission--write { color: #27704e; background: #e2f1e8; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6rem; margin-top: 1.2rem; }
|
||||
.info { min-width: 0; padding: 0.75rem; border: 1px solid #e2eae6; border-radius: 0.8rem; }
|
||||
.info span, .info strong { display: block; }
|
||||
.info span { color: #7a8c94; font-size: 0.72rem; }
|
||||
.info strong { margin-top: 0.3rem; overflow: hidden; color: #284754; font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.section-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; margin-top: 1.5rem; }
|
||||
.section-title { padding-bottom: 0.5rem; border-bottom: 1px solid #e2e9e6; }
|
||||
.data-row { display: flex; align-items: start; justify-content: space-between; gap: 0.7rem; padding: 0.75rem 0; border-bottom: 1px solid #edf1ef; }
|
||||
.data-row strong { color: #284754; font-size: 0.86rem; }
|
||||
.data-row p { margin: 0.2rem 0 0; color: #77878e; font-size: 0.75rem; line-height: 1.45; }
|
||||
.tag { flex: none; padding: 0.25rem 0.5rem; border-radius: 999px; color: #6d7f87; background: #edf1ef; font-size: 0.68rem; font-weight: 800; }
|
||||
.tag--confirmed, .tag--completed, .tag--active { color: #27704e; background: #e2f1e8; }
|
||||
.tag--pending, .tag--preparing, .tag--room_issued, .tag--starting_agent, .tag--stopping { color: #9a5a25; background: #fff0df; }
|
||||
.tag--rejected, .tag--failed, .tag--expired, .tag--revoked { color: #9b4936; background: #fae8e3; }
|
||||
.tag--used { color: #607580; background: #edf1ef; }
|
||||
.empty-copy { color: #84939a; font-size: 0.82rem; line-height: 1.6; }
|
||||
.empty-copy.padded { padding: 1rem; }
|
||||
|
||||
.device-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.9rem; }
|
||||
.device-card { padding: 1.2rem; border: 1px solid #dfe8e4; border-radius: 1rem; }
|
||||
.device-card__head { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 0.7rem; }
|
||||
.device-card__head strong, .device-card__head span { display: block; }
|
||||
.device-card__head > div span { margin-top: 0.15rem; color: #7b8b92; font-size: 0.76rem; }
|
||||
.presence-dot { width: 0.7rem; height: 0.7rem; border-radius: 50%; background: #9aa7ab; box-shadow: 0 0 0 0.3rem #edf1ef; }
|
||||
.presence-dot.online { background: #4b9b72; box-shadow: 0 0 0 0.3rem #e2f1e8; }
|
||||
.sync-bar { display: flex; justify-content: space-between; margin-top: 1rem; padding: 0.75rem; border-radius: 0.8rem; color: #607580; background: #f3f6f4; font-size: 0.8rem; }
|
||||
.device-card__time { margin: 0.7rem 0 0; color: #7a8b92; font-size: 0.74rem; }
|
||||
.device-volume { margin-top: 0.75rem; padding: 0.8rem; border: 1px solid #dfe8e4; border-radius: 0.8rem; background: #fbfcfb; }
|
||||
.device-volume__head, .device-volume__meta { display: flex; align-items: center; justify-content: space-between; gap: 0.8rem; }
|
||||
.device-volume__head label { color: #395762; font-size: 0.78rem; font-weight: 800; }
|
||||
.device-volume__head strong { color: #27704e; font-size: 0.86rem; }
|
||||
.device-volume input[type="range"] { width: 100%; margin: 0.7rem 0 0.45rem; padding: 0; accent-color: #4b9b72; }
|
||||
.device-volume__meta { color: #71858e; font-size: 0.68rem; }
|
||||
.device-volume .row-actions { margin-top: 0.75rem; }
|
||||
|
||||
.firmware-workspace { display: grid; gap: 1rem; }
|
||||
.firmware-tools .tool-form { grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: end; }
|
||||
.firmware-tools .tool-form > div:first-child { grid-column: 1 / -1; }
|
||||
.firmware-tools .tool-form__wide { grid-column: span 2; }
|
||||
.firmware-tools .tool-form > button { justify-self: end; }
|
||||
.firmware-key-ledger { padding: 0.9rem; border: 1px solid #dbe6e1; border-radius: 0.9rem; background: #fbfcfb; }
|
||||
.firmware-key-ledger .section-title { margin-bottom: 0.75rem; }
|
||||
.firmware-key-ledger .section-title p { margin: 0.2rem 0 0; color: #71858e; font-size: 0.7rem; }
|
||||
.firmware-key-card { display: flex; align-items: center; justify-content: space-between; gap: 0.8rem; padding: 0.75rem 0.8rem; border: 1px solid #e0e8e4; border-radius: 0.8rem; background: #f8faf9; }
|
||||
.firmware-key-card > div:first-child { min-width: 0; }
|
||||
.firmware-key-card strong, .firmware-key-card code, .firmware-key-card p { display: block; }
|
||||
.firmware-key-card code { margin-top: 0.25rem; color: #536d77; font-size: 0.68rem; overflow-wrap: anywhere; }
|
||||
.firmware-key-card p { margin: 0.25rem 0 0; color: #7b8b92; font-size: 0.68rem; }
|
||||
.firmware-digest { grid-column: span 3; min-width: 0; padding: 0.7rem 0.85rem; border: 1px dashed #b9cbc3; border-radius: 0.8rem; background: #fff; }
|
||||
.firmware-digest span, .firmware-digest strong, .firmware-digest code { display: block; }
|
||||
.firmware-digest span { color: #71858e; font-size: 0.7rem; }
|
||||
.firmware-digest strong { margin-top: 0.2rem; color: #294956; font-size: 0.78rem; }
|
||||
.firmware-digest code { overflow: hidden; margin-top: 0.25rem; color: #73858c; font-size: 0.68rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.firmware-device-select { grid-column: span 3; display: flex; flex-wrap: wrap; gap: 0.55rem 1rem; min-width: 0; margin: 0; padding: 0.7rem 0.85rem; border: 1px solid #d4dfda; border-radius: 0.8rem; background: #fff; }
|
||||
.firmware-device-select legend { padding: 0 0.35rem; color: #71858e; font-size: 0.7rem; font-weight: 700; }
|
||||
.firmware-device-select label { display: flex; align-items: center; gap: 0.35rem; color: #536d77; font-size: 0.74rem; }
|
||||
.firmware-device-select input { width: auto; accent-color: #cb6d35; }
|
||||
.firmware-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
.panel-section { min-width: 0; padding: 1.15rem; border: 1px solid #dfe8e4; border-radius: 1rem; background: #fff; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.8rem; }
|
||||
.section-heading h3 { margin-top: 0.1rem; color: #244754; font-size: 1.05rem; }
|
||||
.section-heading > span { color: #71858e; font-size: 0.74rem; }
|
||||
.stack-list { display: grid; gap: 0.55rem; }
|
||||
.stack-card { display: flex; align-items: center; justify-content: space-between; gap: 0.8rem; width: 100%; box-sizing: border-box; padding: 0.75rem 0.8rem; border: 1px solid #e0e8e4; border-radius: 0.8rem; color: #294956; background: #f8faf9; text-align: left; }
|
||||
.stack-card > div:first-child strong, .stack-card > div:first-child span { display: block; }
|
||||
.stack-card > div:first-child strong { font-size: 0.8rem; }
|
||||
.stack-card > div:first-child span, .stack-card .row-actions > span:not(.status), .stack-card code { margin-top: 0.2rem; color: #7b8b92; font-size: 0.68rem; }
|
||||
.stack-card--button:hover, .stack-card--button.selected { transform: none; border-color: #9fbaae; background: #eef5f1; }
|
||||
.firmware-recovery-form { display: grid; gap: 0.75rem; margin-top: 0.9rem; padding: 0.9rem; border: 1px solid #d7e3dd; border-radius: 0.85rem; background: #f7faf8; }
|
||||
.firmware-recovery-form > div:first-child p { margin: 0.25rem 0 0; color: #71858e; font-size: 0.72rem; }
|
||||
.firmware-recovery-form label { display: grid; gap: 0.35rem; color: #405b65; font-size: 0.75rem; font-weight: 700; }
|
||||
.firmware-recovery-form label small { color: #71858e; font-size: 0.68rem; font-weight: 400; }
|
||||
.firmware-recovery-form .firmware-digest { grid-column: auto; }
|
||||
.firmware-recovery-form .firmware-digest code { overflow-wrap: anywhere; white-space: normal; }
|
||||
.firmware-recovery-form .workspace__action-message, .firmware-recovery-form .workspace__inline-error { margin: 0; }
|
||||
.firmware-impact { display: grid; gap: 0.75rem; margin-top: 0.9rem; padding: 0.9rem; border: 1px solid #d4e2dc; border-radius: 0.85rem; background: #f8faf9; }
|
||||
.firmware-impact .section-title p { margin: 0.2rem 0 0; color: #71858e; font-size: 0.68rem; overflow-wrap: anywhere; }
|
||||
.firmware-impact__metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.55rem; }
|
||||
.firmware-impact__metrics article { min-width: 0; padding: 0.65rem 0.7rem; border: 1px solid #e0e8e4; border-radius: 0.7rem; background: #fff; }
|
||||
.firmware-impact__metrics span, .firmware-impact__metrics strong, .firmware-impact__metrics small { display: block; }
|
||||
.firmware-impact__metrics span { color: #71858e; font-size: 0.66rem; }
|
||||
.firmware-impact__metrics strong { margin: 0.12rem 0; color: #294956; font-size: 1.05rem; }
|
||||
.firmware-impact__metrics small { color: #71858e; font-size: 0.64rem; line-height: 1.45; }
|
||||
.firmware-impact__devices { display: grid; gap: 0.45rem; }
|
||||
.firmware-impact__devices article { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 0.75rem; padding: 0.65rem 0.7rem; border: 1px solid #e0e8e4; border-radius: 0.7rem; background: #fff; }
|
||||
.firmware-impact__devices strong, .firmware-impact__devices code, .firmware-impact__devices span, .firmware-impact__devices small { display: block; }
|
||||
.firmware-impact__devices code { margin: 0.15rem 0; color: #607580; font-size: 0.64rem; overflow-wrap: anywhere; }
|
||||
.firmware-impact__devices span, .firmware-impact__devices small { color: #71858e; font-size: 0.64rem; }
|
||||
.firmware-impact__devices > article > div:last-child { text-align: right; }
|
||||
.firmware-impact__devices > article > div:last-child strong { margin: 0.2rem 0; color: #395762; font-size: 0.7rem; }
|
||||
.firmware-detail__actions { display: flex; align-items: end; justify-content: flex-end; gap: 0.55rem; margin-bottom: 1rem; padding: 0.8rem; border-radius: 0.8rem; background: #f5f8f6; }
|
||||
.firmware-detail__actions label { display: grid; gap: 0.3rem; margin-right: auto; }
|
||||
.firmware-detail__actions label span { color: #71858e; font-size: 0.7rem; font-weight: 700; }
|
||||
.firmware-assignment-list { display: grid; gap: 0.6rem; }
|
||||
.firmware-assignment { display: grid; grid-template-columns: minmax(12rem, 1fr) minmax(15rem, 1.4fr); align-items: center; gap: 1rem; padding: 0.8rem; border: 1px solid #e0e8e4; border-radius: 0.8rem; }
|
||||
.firmware-assignment > div:first-child strong, .firmware-assignment > div:first-child span { display: block; }
|
||||
.firmware-assignment > div:first-child strong { color: #294956; font-size: 0.8rem; }
|
||||
.firmware-assignment > div:first-child span { margin-top: 0.2rem; color: #7b8b92; font-size: 0.68rem; }
|
||||
.firmware-progress > div { display: flex; justify-content: space-between; color: #607580; font-size: 0.72rem; }
|
||||
.firmware-progress progress { width: 100%; height: 0.55rem; margin-top: 0.35rem; accent-color: #4b9b72; }
|
||||
.firmware-progress small { display: block; margin-top: 0.25rem; color: #71858e; font-size: 0.66rem; line-height: 1.35; }
|
||||
.firmware-event-timeline { grid-column: 1 / -1; display: grid; gap: 0.35rem; margin: 0; padding: 0.65rem 0 0; border-top: 1px solid #edf1ef; list-style: none; }
|
||||
.firmware-event-timeline li { display: grid; grid-template-columns: 8rem minmax(12rem, 1fr) auto; align-items: baseline; gap: 0.55rem; color: #536c77; font-size: 0.67rem; }
|
||||
.firmware-event-timeline time { color: #829198; font-variant-numeric: tabular-nums; }
|
||||
.firmware-event-timeline code { max-width: 18rem; overflow-wrap: anywhere; color: #476b5b; font-size: 0.62rem; }
|
||||
.firmware-event-timeline__empty { grid-column: 1 / -1; margin: 0; padding-top: 0.55rem; border-top: 1px solid #edf1ef; color: #8a989e; font-size: 0.66rem; }
|
||||
.upload-queue { margin-top: 0.65rem; padding: 0.7rem 0.75rem; border: 1px solid #dfe8e4; border-radius: 0.75rem; background: #f5f8f6; }
|
||||
.upload-queue > div { display: flex; align-items: center; justify-content: space-between; gap: 0.8rem; }
|
||||
.upload-queue span { color: #71858e; font-size: 0.68rem; }
|
||||
.upload-queue strong { color: #395762; font-size: 0.72rem; }
|
||||
.upload-queue p { margin: 0.35rem 0 0; color: #71858e; font-size: 0.66rem; line-height: 1.45; }
|
||||
.upload-queue--pending { border-color: #edd5aa; background: #fff8e9; }
|
||||
.upload-queue--pending strong { color: #8a5a20; }
|
||||
.upload-queue--failed { border-color: #e7c4bb; background: #faece8; }
|
||||
.upload-queue--failed strong, .upload-queue--failed p { color: #934633; }
|
||||
|
||||
.conversation-layout { display: grid; grid-template-columns: 0.9fr 1.1fr; min-height: 31rem; border: 1px solid #dfe8e4; border-radius: 1.1rem; overflow: hidden; }
|
||||
.conversation-list { border-right: 1px solid #dfe8e4; background: #f6f8f7; }
|
||||
.conversation-filter { padding: 1rem; }
|
||||
.conversation-filter__fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.55rem; }
|
||||
.conversation-filter label { width: auto; min-width: 0; }
|
||||
.conversation-filter label:first-child { grid-column: 1 / -1; }
|
||||
.conversation-filter select, .conversation-filter input { min-width: 0; width: 100%; box-sizing: border-box; }
|
||||
.conversation-filter__summary { display: flex; align-items: center; justify-content: space-between; gap: 0.7rem; margin-top: 0.7rem; }
|
||||
.conversation-filter__summary > span { color: #7b8b92; font-size: 0.72rem; }
|
||||
.conversation-list__error { margin: 0 1rem 0.8rem; padding: 0.65rem 0.75rem; border-radius: 0.7rem; color: #934633; background: #fae8e3; font-size: 0.72rem; }
|
||||
.conversation-list__more { display: flex; justify-content: center; padding: 0.9rem; border-top: 1px solid #e6ece9; }
|
||||
.conversation-list__more button { width: 100%; }
|
||||
.conversation-row { display: flex; align-items: center; justify-content: space-between; margin: 0; border-radius: 0; border-top: 1px solid #e6ece9; }
|
||||
.conversation-row div strong, .conversation-row div span { display: block; }
|
||||
.conversation-row div span { margin-top: 0.25rem; color: #7b8b92; font-size: 0.74rem; font-weight: 500; }
|
||||
.conversation-detail { padding: 1.4rem; }
|
||||
.timeline { margin-top: 1.3rem; }
|
||||
.timeline h4 { margin-bottom: 0.6rem; }
|
||||
.timeline__item { display: grid; grid-template-columns: auto 1fr; gap: 0.8rem; min-height: 3.4rem; }
|
||||
.timeline__item > span { width: 0.55rem; height: 0.55rem; margin-top: 0.25rem; border-radius: 50%; background: #5c9b7a; box-shadow: 0 0 0 0.25rem #e5f0ea; }
|
||||
.timeline__item strong { color: #34515f; font-size: 0.82rem; }
|
||||
.timeline__item p { margin: 0.2rem 0 0; color: #809097; font-size: 0.72rem; }
|
||||
.content-block { margin-top: 1.25rem; }
|
||||
.recording-block { padding: 1rem; border: 1px solid #dfe8e4; border-radius: 1rem; background: #fbfcfb; }
|
||||
.recording-notice { margin: 0.7rem 0 0; padding: 0.75rem; border-radius: 0.75rem; color: #647982; background: #eef3f1; font-size: 0.76rem; line-height: 1.55; }
|
||||
.recording-notice--restricted { color: #7f571f; background: #fff2dc; }
|
||||
.recording-notice--error { color: #934633; background: #fae8e3; }
|
||||
.recording-capabilities { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; margin-top: 0.7rem; }
|
||||
.recording-capabilities small { flex: 1 1 16rem; color: #7b8b92; font-size: 0.68rem; text-align: right; }
|
||||
.recording-segments { display: grid; gap: 0.55rem; margin-top: 0.75rem; }
|
||||
.recording-segment { display: grid; grid-template-columns: minmax(10rem, 1fr) auto; align-items: center; gap: 0.6rem; padding: 0.75rem; border: 1px solid #e2e9e6; border-radius: 0.8rem; background: #fff; }
|
||||
.recording-segment > div:first-child strong, .recording-segment > div:first-child span { display: block; }
|
||||
.recording-segment > div:first-child strong { color: #294956; font-size: 0.78rem; }
|
||||
.recording-segment > div:first-child span { margin-top: 0.2rem; color: #7b8b92; font-size: 0.68rem; }
|
||||
.recording-audio { grid-column: 1 / -1; width: 100%; height: 2.25rem; }
|
||||
.recording-admin { display: grid; gap: 0.8rem; margin-top: 0.9rem; padding-top: 0.9rem; border-top: 1px dashed #d6e1dc; }
|
||||
.recording-grant-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: end; gap: 0.55rem; }
|
||||
.recording-grant-form > div { grid-column: 1 / -1; }
|
||||
.recording-grant-form > div strong, .recording-grant-form > div span { display: block; }
|
||||
.recording-grant-form > div strong { color: #294956; font-size: 0.8rem; }
|
||||
.recording-grant-form > div span { margin-top: 0.2rem; color: #7b8b92; font-size: 0.67rem; line-height: 1.4; }
|
||||
.recording-grant-form label span { font-size: 0.66rem; }
|
||||
.recording-grant-form input, .recording-grant-form select { width: 100%; min-width: 0; box-sizing: border-box; padding: 0.55rem 0.6rem; font-size: 0.72rem; }
|
||||
.recording-grant-form button { justify-self: end; padding: 0.6rem 0.7rem; font-size: 0.7rem; white-space: nowrap; }
|
||||
.recording-grants > article { display: flex; align-items: center; justify-content: space-between; gap: 0.7rem; padding: 0.55rem 0; border-bottom: 1px solid #e8eeeb; }
|
||||
.recording-grants > article strong, .recording-grants > article span { display: block; }
|
||||
.recording-grants > article strong { color: #395762; font-size: 0.73rem; }
|
||||
.recording-grants > article span { margin-top: 0.2rem; color: #7b8b92; font-size: 0.66rem; }
|
||||
.summary-card { margin-top: 0.65rem; padding: 0.8rem; border-radius: 0.8rem; background: #f2f6f4; }
|
||||
.summary-card strong { color: #294956; font-size: 0.82rem; }
|
||||
.summary-card p { margin: 0.35rem 0; color: #506a75; font-size: 0.82rem; line-height: 1.6; }
|
||||
.summary-card small { color: #829198; }
|
||||
.turn-row { margin-top: 0.6rem; padding: 0.75rem 0.85rem; border: 1px solid #e0e9e5; border-radius: 0.85rem; }
|
||||
.turn-row--subject { margin-right: 2rem; background: #eef6f1; }
|
||||
.turn-row--agent { margin-left: 2rem; background: #f7f5f1; }
|
||||
.turn-row > div { display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.turn-row strong { color: #294956; font-size: 0.8rem; }
|
||||
.turn-row span { color: #829198; font-size: 0.7rem; }
|
||||
.turn-row p { margin: 0.35rem 0 0; color: #526d77; font-size: 0.82rem; line-height: 1.55; }
|
||||
|
||||
.story-layout { display: grid; grid-template-columns: 17rem 1fr; min-height: 34rem; border: 1px solid #dfe8e4; border-radius: 1.1rem; overflow: hidden; }
|
||||
.story-sidebar { padding: 1rem; border-right: 1px solid #dfe8e4; background: #f5f8f6; }
|
||||
.story-sidebar > label { display: block; margin-bottom: 1rem; }
|
||||
.story-sidebar > label select { width: 100%; }
|
||||
.story-button { width: 100%; padding: 0.75rem; border-radius: 0.8rem; text-align: left; color: #385561; background: transparent; }
|
||||
.story-button:hover { transform: none; background: #e7efeb; }
|
||||
.story-button.active { background: #fff; box-shadow: 0 0.35rem 1rem rgba(24, 55, 66, 0.07); }
|
||||
.story-button strong, .story-button span { display: block; }
|
||||
.story-button strong { font-size: 0.82rem; line-height: 1.45; }
|
||||
.story-button span { margin-top: 0.25rem; color: #7c8d94; font-size: 0.7rem; }
|
||||
.memory-title { margin-top: 1.3rem; }
|
||||
.memory-search { width: 100%; box-sizing: border-box; margin-top: 0.65rem; border: 1px solid #ccd9d4; border-radius: 0.7rem; padding: 0.6rem 0.7rem; color: #183548; background: #fff; font: inherit; font-size: 0.76rem; }
|
||||
.memory-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .45rem; padding: 0.7rem 0; border-bottom: 1px solid #e1e9e5; }
|
||||
.memory-row.selected { margin: 0 -.45rem; padding: .7rem .45rem; border-radius: .7rem; background: #e7efeb; }
|
||||
.memory-row__select { min-width: 0; padding: 0; color: inherit; background: transparent; text-align: left; }
|
||||
.memory-row__select:hover { transform: none; background: transparent; }
|
||||
.memory-row strong, .memory-row span { display: block; }
|
||||
.memory-row strong { overflow: hidden; color: #385561; font-size: 0.78rem; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.memory-row span { margin-top: 0.25rem; color: #7c8d94; font-size: 0.68rem; }
|
||||
.story-detail { min-width: 0; padding: 1.4rem; }
|
||||
.story-editor, .story-reason { display: block; margin-top: 1rem; color: #607580; font-size: 0.76rem; font-weight: 700; }
|
||||
.story-editor textarea, .story-reason input { width: 100%; box-sizing: border-box; margin-top: 0.35rem; border: 1px solid #ccd9d4; border-radius: 0.8rem; padding: 0.85rem; color: #183548; background: #fff; font: inherit; line-height: 1.65; resize: vertical; }
|
||||
.story-editor textarea:disabled { color: #526a74; background: #f4f7f5; }
|
||||
.story-actions { display: flex; flex-wrap: wrap; gap: 0.55rem; margin-top: 0.8rem; }
|
||||
.memory-management { min-width: 0; }
|
||||
.memory-management__content { margin-top: 1rem; padding: 1rem; border: 1px solid #dfe8e4; border-radius: .9rem; background: #f7faf8; }
|
||||
.memory-management__content span, .memory-management__content small { color: #71858e; font-size: .72rem; }
|
||||
.memory-management__content p { margin: .45rem 0; color: #2f4f5b; font-size: .9rem; line-height: 1.7; white-space: pre-wrap; }
|
||||
.memory-management__tabs { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1rem; padding: .4rem; border-radius: .8rem; background: #eef3f1; }
|
||||
.memory-management__tabs button { padding: .55rem .8rem; color: #526c77; background: transparent; box-shadow: none; }
|
||||
.memory-management__tabs button:hover { transform: none; background: #fff; }
|
||||
.memory-management__tabs button.active { color: #fff; background: #245f51; }
|
||||
.memory-management__tabs button.danger { color: #934633; }
|
||||
.memory-management__tabs button.danger.active { color: #fff; background: #a84f39; }
|
||||
.memory-management__form { display: grid; gap: .8rem; margin-top: .8rem; padding: 1rem; border: 1px solid #dfe8e4; border-radius: .9rem; background: #fff; }
|
||||
.memory-management__form > p { margin: 0; color: #657b84; font-size: .78rem; line-height: 1.6; }
|
||||
.memory-management__form label:not(.consent-confirm) { display: grid; gap: .35rem; color: #607580; font-size: .76rem; font-weight: 700; }
|
||||
.memory-management__form input, .memory-management__form textarea { width: 100%; min-width: 0; box-sizing: border-box; border: 1px solid #ccd9d4; border-radius: .75rem; padding: .75rem .8rem; color: #183548; background: #fff; font: inherit; }
|
||||
.memory-management__form textarea { resize: vertical; line-height: 1.65; }
|
||||
.memory-management__form > button { justify-self: end; }
|
||||
.memory-management__form--delete { border-color: #e4b8ad; background: #fff7f4; }
|
||||
.memory-management__form--delete > p { color: #815d54; }
|
||||
.memory-management__form--delete > button:not(:disabled) { color: #fff; background: #a84f39; }
|
||||
.source-row { position: relative; padding: 0.75rem 0; border-bottom: 1px solid #edf1ef; }
|
||||
.source-row strong { color: #34515f; font-size: 0.78rem; }
|
||||
.source-row p { margin: 0.3rem 0; color: #697d86; font-size: 0.78rem; line-height: 1.55; }
|
||||
.state-panel { display: grid; place-content: center; min-height: 12rem; padding: 2rem; text-align: center; }
|
||||
.state-panel strong { color: #294956; }
|
||||
.state-panel p { max-width: 30rem; margin: 0.5rem auto 0; color: #7a8b92; line-height: 1.6; }
|
||||
.state-panel button { justify-self: center; margin-top: 0.8rem; }
|
||||
|
||||
.privacy-layout { display: grid; gap: 1rem; }
|
||||
.privacy-toolbar { display: grid; grid-template-columns: minmax(11rem, .7fr) minmax(18rem, 1.8fr) auto; align-items: end; gap: 1rem; padding: 1.2rem; border: 1px solid #dfe8e4; border-radius: 1rem; background: #f8faf9; }
|
||||
.privacy-toolbar label { display: grid; min-width: 0; gap: 0.35rem; }
|
||||
.privacy-toolbar select { width: 100%; min-width: 0; }
|
||||
.privacy-toolbar label span, .privacy-toolbar p { color: #71858e; font-size: 0.78rem; }
|
||||
.privacy-toolbar p { margin: 0.3rem 0 0; line-height: 1.55; }
|
||||
.privacy-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); gap: 0.9rem; }
|
||||
.privacy-card { padding: 1.2rem; border: 1px solid #dfe8e4; border-radius: 1rem; background: #fff; box-shadow: 0 0.45rem 1.2rem rgba(24, 55, 66, 0.05); }
|
||||
.privacy-card__head { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.8rem; }
|
||||
.privacy-card__head div { display: grid; gap: 0.25rem; }
|
||||
.privacy-card__head div span, .privacy-card > p { color: #71858e; font-size: 0.76rem; }
|
||||
.privacy-counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.5rem; margin: 1rem 0; }
|
||||
.privacy-counts .info { min-width: 0; }
|
||||
.privacy-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; }
|
||||
.privacy-exports { padding: 1.2rem; border: 1px solid #dfe8e4; border-radius: 1rem; background: #fff; }
|
||||
.privacy-delete-confirmation { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .85rem; padding: 1.2rem; border: 1px solid #e4b8ad; border-radius: 1rem; background: #fff7f4; box-shadow: 0 .6rem 1.5rem rgba(120, 54, 38, .08); }
|
||||
.privacy-delete-confirmation__head, .privacy-delete-confirmation > p, .privacy-delete-confirmation__error, .privacy-delete-confirmation__actions { grid-column: 1 / -1; }
|
||||
.privacy-delete-confirmation__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||
.privacy-delete-confirmation__head h4 { margin-top: .15rem; color: #713d31; font-size: 1.05rem; }
|
||||
.privacy-delete-confirmation > p { margin: 0; color: #815d54; font-size: .78rem; line-height: 1.6; }
|
||||
.privacy-delete-confirmation label { display: grid; min-width: 0; gap: .35rem; color: #744b42; font-size: .76rem; font-weight: 700; }
|
||||
.privacy-delete-confirmation textarea, .privacy-delete-confirmation input { width: 100%; min-width: 0; box-sizing: border-box; border: 1px solid #d8b7af; border-radius: .75rem; padding: .7rem .8rem; color: #412d29; background: #fff; font: inherit; }
|
||||
.privacy-delete-confirmation textarea { min-height: 5.2rem; resize: vertical; }
|
||||
.privacy-delete-confirmation input[aria-invalid="true"] { border-color: #b45d49; box-shadow: 0 0 0 .18rem rgba(180, 93, 73, .12); }
|
||||
.privacy-delete-confirmation__error { color: #934633 !important; font-weight: 700; }
|
||||
.privacy-delete-confirmation__actions { display: flex; justify-content: flex-end; gap: .55rem; }
|
||||
.privacy-delete-confirmation__actions button[type="submit"]:not(:disabled) { color: #fff; background: #a84f39; box-shadow: 0 .35rem .8rem rgba(139, 59, 40, .18); }
|
||||
|
||||
.ops-workspace { margin-top: 1rem; padding: 2rem; border: 1px solid #dce7e2; border-radius: 1.5rem; background: rgba(255,255,255,.9); box-shadow: 0 1.2rem 3rem rgba(24,55,66,.08); }
|
||||
.ops-workspace__head { display: flex; align-items: end; justify-content: space-between; gap: 2rem; margin-bottom: 1.2rem; }
|
||||
.ops-workspace__head h2 { margin: .25rem 0; }
|
||||
.ops-workspace__head p { margin: 0; color: #6e828b; line-height: 1.55; }
|
||||
.ops-workspace__head label { display: grid; min-width: 18rem; gap: .35rem; color: #657982; font-size: .76rem; font-weight: 700; }
|
||||
.ops-workspace select, .ops-workspace input, .ops-workspace textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd9d3; border-radius: .75rem; padding: .7rem .8rem; color: #183548; background: #fff; font: inherit; }
|
||||
.ops-workspace textarea { resize: vertical; font-family: "Cascadia Mono", Consolas, monospace; font-size: .76rem; line-height: 1.55; }
|
||||
.ops-config-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); align-items: end; gap: .8rem; margin: 1rem 0; padding: 1.2rem; border: 1px solid #dfe8e4; border-radius: 1rem; background: #f7faf8; }
|
||||
.ops-config-form label { display: grid; min-width: 0; gap: .35rem; color: #657982; font-size: .76rem; font-weight: 700; }
|
||||
.ops-config-form__intro, .ops-config-form__wide { grid-column: 1 / -1; }
|
||||
.ops-config-form__intro strong, .ops-config-form__intro span { display: block; }
|
||||
.ops-config-form__intro strong { color: #284754; }
|
||||
.ops-config-form__intro span { margin-top: .2rem; color: #778990; font-size: .74rem; }
|
||||
.ops-bundle-composer { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .75rem; min-width: 0; margin: 0; padding: 1rem; border: 1px solid #d9e4df; border-radius: .85rem; background: #fff; }
|
||||
.ops-bundle-composer legend { padding: 0 .35rem; color: #284754; font-size: .78rem; font-weight: 800; }
|
||||
.ops-bundle-composer > small { grid-column: 1 / -1; color: #70848d; font-size: .72rem; line-height: 1.5; }
|
||||
.ops-bundle-composer > small.ops-bundle-composer__warning { color: #9a4c2d; }
|
||||
.ops-config-form__note { margin: 0; padding: .75rem .85rem; border-radius: .75rem; color: #617780; background: #edf4f1; font-size: .74rem; line-height: 1.5; }
|
||||
.ops-config-form button { justify-self: end; }
|
||||
.ops-gate-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .8rem; margin: 1rem 0; }
|
||||
.ops-gate-card { display: grid; align-content: start; gap: .65rem; padding: 1rem; border: 1px solid #dfe8e4; border-radius: 1rem; background: #f7faf8; }
|
||||
.ops-gate-card > p { margin: -.35rem 0 0; color: #71858d; font-size: .73rem; }
|
||||
.ops-gate-card label { display: grid; gap: .3rem; color: #657982; font-size: .74rem; font-weight: 700; }
|
||||
.ops-gate-card button { justify-self: start; }
|
||||
.ops-gate-card small { color: #70848d; }
|
||||
.ops-ledger-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .8rem; margin: 1rem 0; }
|
||||
.ops-ledger { overflow: hidden; border: 1px solid #dfe8e4; border-radius: 1rem; background: #fff; }
|
||||
.ops-ledger > div { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; border-bottom: 1px solid #e6ece9; background: #f4f8f6; }
|
||||
.ops-ledger > div span, .ops-ledger > p { color: #71858d; font-size: .72rem; }
|
||||
.ops-ledger > p { margin: 0; padding: 1rem; }
|
||||
.ops-ledger ul { max-height: 15rem; overflow: auto; margin: 0; padding: 0; list-style: none; }
|
||||
.ops-ledger li { display: flex; align-items: center; justify-content: space-between; gap: .8rem; padding: .7rem 1rem; border-bottom: 1px solid #edf1ef; color: #637a83; font-size: .72rem; }
|
||||
.ops-ledger li:last-child { border-bottom: 0; }
|
||||
.ops-ledger li > span:first-child { display: grid; min-width: 0; gap: .15rem; }
|
||||
.ops-ledger li small { overflow-wrap: anywhere; color: #71858d; }
|
||||
.external-dependency-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: .75rem; }
|
||||
.external-dependency-grid .diagnostic-card { margin: 0; }
|
||||
.ops-rollout-panel { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); align-items: end; gap: .8rem; margin: 1rem 0; padding: 1rem; border: 1px solid #d6e4de; border-radius: 1rem; background: #f0f7f3; }
|
||||
.ops-rollout-panel > div, .ops-rollout-panel__reason { grid-column: 1 / -1; }
|
||||
.ops-rollout-panel > div p { margin: .25rem 0 0; color: #71858d; font-size: .74rem; }
|
||||
.ops-rollout-panel label { display: grid; gap: .35rem; color: #657982; font-size: .74rem; font-weight: 700; }
|
||||
.ops-version-list { margin-top: 1rem; }
|
||||
.ops-version-group { margin-top: .8rem; overflow: hidden; border: 1px solid #dfe8e4; border-radius: 1rem; background: #fff; }
|
||||
.ops-version-group > div:first-child { display: flex; justify-content: space-between; gap: 1rem; padding: .9rem 1rem; border-bottom: 1px solid #e5ece8; background: #f4f8f6; }
|
||||
.ops-version-group > div:first-child span { color: #70848d; font-size: .75rem; }
|
||||
.ops-version { display: grid; grid-template-columns: minmax(14rem,.8fr) minmax(20rem,1.6fr) auto; align-items: center; gap: 1rem; padding: 1rem; border-bottom: 1px solid #edf1ef; }
|
||||
.ops-version:last-child { border-bottom: 0; }
|
||||
.ops-version__summary { display: grid; gap: .25rem; }
|
||||
.ops-version__summary span, .ops-version__summary small { color: #70848d; font-size: .73rem; line-height: 1.45; }
|
||||
.ops-version details summary { color: #3c5d69; font-size: .76rem; cursor: pointer; }
|
||||
.ops-diff { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; margin-top: .5rem; }
|
||||
.ops-diff pre { max-height: 14rem; overflow: auto; margin: 0; padding: .6rem; border-radius: .55rem; color: #395965; background: #eef4f1; font-size: .68rem; white-space: pre-wrap; }
|
||||
.ops-version details p { margin: .4rem 0 0; color: #72858d; font-size: .7rem; }
|
||||
.ops-version__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .4rem; }
|
||||
.ops-version-transition { grid-column: 1 / -1; width: 100%; }
|
||||
.ops-version-transition__scope { grid-column: 1 / -1; margin: 0; }
|
||||
.ops-evidence-list { grid-column: 1 / -1; display: grid; gap: .35rem; margin: 0; padding: .6rem .8rem; border-radius: .7rem; background: #f4f8f6; list-style: none; }
|
||||
.ops-evidence-list li { display: flex; gap: .7rem; color: #637a83; font-size: .72rem; }
|
||||
.ops-evidence-list strong { color: #2d725b; }
|
||||
.ops-evaluation-editor { grid-column: 1 / -1; margin-top: 0; }
|
||||
.ops-evaluation-editor h4 { margin: .1rem 0 0; color: #4e4a3c; }
|
||||
.ops-billing { background: linear-gradient(145deg,rgba(255,255,255,.96),rgba(247,251,249,.96)); }
|
||||
.billing-summary-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: .8rem; margin: 1rem 0; }
|
||||
.billing-summary-grid article { display: grid; gap: .3rem; padding: 1rem; border: 1px solid #dce7e2; border-radius: 1rem; background: #f4f8f6; }
|
||||
.billing-summary-grid span, .billing-summary-grid small { color: #71858d; font-size: .72rem; }
|
||||
.billing-summary-grid strong { color: #234957; font-size: 1.3rem; }
|
||||
.billing-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); align-items: end; gap: .8rem; margin: 1rem 0; padding: 1.2rem; border: 1px solid #dce7e2; border-radius: 1rem; background: #f7faf8; }
|
||||
.billing-form label { display: grid; min-width: 0; gap: .35rem; color: #657982; font-size: .76rem; font-weight: 700; }
|
||||
.billing-form__intro, .billing-form__wide { grid-column: 1 / -1; }
|
||||
.billing-form__intro { display: grid; gap: .2rem; }
|
||||
.billing-form__intro span { color: #71858d; font-size: .74rem; }
|
||||
.billing-form button { justify-self: end; }
|
||||
.billing-form--quota { border-color: #d5e1e9; background: #f5f8fa; }
|
||||
.billing-rate-table { overflow-x: auto; border: 1px solid #dfe8e4; border-radius: .8rem; background: #fff; }
|
||||
.billing-rate-table > div, .billing-rate-table label { display: grid; grid-template-columns: minmax(7rem,.8fr) minmax(10rem,1.3fr) minmax(7rem,.8fr) minmax(9rem,.9fr); align-items: center; gap: .5rem; padding: .55rem .7rem; }
|
||||
.billing-rate-table > div { color: #71858d; background: #edf4f1; font-size: .7rem; font-weight: 700; }
|
||||
.billing-rate-table label { border-top: 1px solid #edf1ef; }
|
||||
.billing-rate-table label strong { color: #3d5e69; font-size: .72rem; }
|
||||
.billing-rate-table input { padding: .52rem .6rem; font-size: .72rem; }
|
||||
.billing-ledger-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .8rem; margin: 1rem 0; }
|
||||
.billing-ledger, .billing-policy-list { overflow: hidden; border: 1px solid #dfe8e4; border-radius: 1rem; background: #fff; }
|
||||
.billing-ledger > div, .billing-policy-list > div { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; border-bottom: 1px solid #e6ece9; background: #f4f8f6; }
|
||||
.billing-ledger > div span, .billing-policy-list > div span, .billing-ledger > p, .billing-policy-list > p { color: #71858d; font-size: .72rem; }
|
||||
.billing-ledger > p, .billing-policy-list > p { margin: 0; padding: 1rem; }
|
||||
.billing-ledger ul, .billing-policy-list ul { max-height: 19rem; overflow: auto; margin: 0; padding: 0; list-style: none; }
|
||||
.billing-ledger li, .billing-policy-list li { display: flex; align-items: center; justify-content: space-between; gap: .8rem; padding: .75rem 1rem; border-bottom: 1px solid #edf1ef; color: #637a83; font-size: .72rem; }
|
||||
.billing-ledger li:last-child, .billing-policy-list li:last-child { border-bottom: 0; }
|
||||
.billing-ledger li > span:first-child, .billing-policy-list li > span:first-child { display: grid; min-width: 0; gap: .2rem; }
|
||||
.billing-ledger small, .billing-policy-list small { overflow-wrap: anywhere; color: #71858d; }
|
||||
.billing-ledger b { color: #2d725b; white-space: nowrap; }
|
||||
.conversation-usage { display: grid; grid-template-columns: minmax(12rem,.8fr) minmax(16rem,1.4fr); gap: 1rem; margin: 1rem 0; padding: 1rem; border: 1px solid #dce7e2; border-radius: 1rem; background: #f4f8f6; }
|
||||
.conversation-usage > div { display: grid; align-content: start; gap: .25rem; }
|
||||
.conversation-usage > div span, .conversation-usage small, .conversation-usage > p { color: #71858d; font-size: .72rem; }
|
||||
.conversation-usage > div strong { color: #245567; font-size: 1.15rem; }
|
||||
.conversation-usage ul { margin: 0; padding: 0; list-style: none; }
|
||||
.conversation-usage li { display: flex; justify-content: space-between; gap: .8rem; padding: .35rem 0; border-bottom: 1px solid #e3ebe7; color: #607781; font-size: .72rem; }
|
||||
.conversation-usage li:last-child { border-bottom: 0; }
|
||||
.conversation-usage li strong { color: #2d725b; white-space: nowrap; }
|
||||
.conversation-usage > p { margin: 0; }
|
||||
|
||||
.ops-alerts { background: linear-gradient(145deg,rgba(255,255,255,.97),rgba(248,250,249,.97)); }
|
||||
.ops-diagnostics { background: linear-gradient(145deg,rgba(255,255,255,.98),rgba(245,250,252,.98)); }
|
||||
.diagnostic-search { display: grid; grid-template-columns: minmax(12rem,.45fr) minmax(20rem,1fr) auto; align-items: end; gap: .8rem; margin: 1rem 0; padding: 1rem; border: 1px solid #d9e6e7; border-radius: 1rem; background: #f5f9fa; }
|
||||
.diagnostic-search label { display: grid; gap: .35rem; color: #657982; font-size: .76rem; font-weight: 700; }
|
||||
.diagnostic-results { display: grid; gap: .8rem; margin-top: 1rem; }
|
||||
.diagnostic-card { padding: 1rem; border: 1px solid #dce7e7; border-radius: 1rem; background: #fff; }
|
||||
.diagnostic-card__head { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
|
||||
.diagnostic-card__head > div { display: grid; gap: .25rem; }
|
||||
.diagnostic-card__head strong { color: #284a59; overflow-wrap: anywhere; }
|
||||
.diagnostic-card__head span { color: #71858d; font-size: .74rem; }
|
||||
.diagnostic-card dl { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: .65rem; margin: 1rem 0; }
|
||||
.diagnostic-card dl div { min-width: 0; padding: .7rem; border-radius: .7rem; background: #f5f8f8; }
|
||||
.diagnostic-card dt { color: #71858d; font-size: .68rem; }
|
||||
.diagnostic-card dd { margin: .25rem 0 0; color: #355967; font: .72rem/1.5 "Cascadia Mono",Consolas,monospace; overflow-wrap: anywhere; }
|
||||
.sensitive-access-request { display: grid; grid-template-columns: 1.2fr .8fr; gap: .8rem 1rem; margin-top: 1rem; padding: 1rem; border: 1px solid #e4d5b8; border-radius: 1rem; background: #fffaf0; }
|
||||
.sensitive-access-request__head, .sensitive-access-request > p, .sensitive-access-request > label:first-of-type, .sensitive-access-request__actions { grid-column: 1 / -1; }
|
||||
.sensitive-access-request__head { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
|
||||
.sensitive-access-request__head h3 { margin-top: .1rem; color: #4e4a3c; }
|
||||
.sensitive-access-request > p { margin: 0; color: #746b58; font-size: .75rem; line-height: 1.6; }
|
||||
.sensitive-access-request > p strong { overflow-wrap: anywhere; color: #4d5e62; }
|
||||
.sensitive-access-request label, .sensitive-access-request fieldset { display: grid; gap: .35rem; color: #657982; font-size: .72rem; font-weight: 700; }
|
||||
.sensitive-access-request textarea { min-height: 5rem; resize: vertical; }
|
||||
.sensitive-access-request fieldset { grid-template-columns: 1fr; margin: 0; padding: .75rem; border: 1px solid #ded7c8; border-radius: .7rem; background: rgba(255,255,255,.7); }
|
||||
.sensitive-access-request fieldset legend { padding: 0 .3rem; }
|
||||
.sensitive-access-request fieldset label { display: flex; align-items: center; gap: .45rem; font-weight: 600; }
|
||||
.sensitive-access-request fieldset input { width: auto; accent-color: #c56a32; }
|
||||
.sensitive-access-request__actions { display: flex; justify-content: flex-end; gap: .5rem; }
|
||||
.controlled-operation--danger { border-color: #e8c3ba; background: #fff7f4; }
|
||||
.controlled-operation__confirmation { grid-column: 1 / -1; display: flex !important; align-items: flex-start; gap: .55rem !important; padding: .75rem; border: 1px solid #e4d5b8; border-radius: .7rem; background: rgba(255,255,255,.75); font-weight: 600 !important; line-height: 1.5; }
|
||||
.controlled-operation__confirmation input { flex: 0 0 auto; width: auto; margin-top: .12rem; accent-color: #b64f3f; }
|
||||
form[aria-label="联调问题流转编辑"] > label { grid-column: 1 / -1; }
|
||||
form[aria-label="联调问题流转编辑"] label small { color: #7b898d; font-weight: 500; line-height: 1.5; }
|
||||
.integration-issues { display: grid; gap: .8rem; }
|
||||
.integration-issues .section-heading { align-items: end; }
|
||||
.integration-issues .section-heading p { margin: .25rem 0 0; color: #71858d; font-size: .74rem; }
|
||||
.issue-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: .8rem; }
|
||||
.issue-summary-grid article { display: grid; gap: .25rem; padding: 1rem; border: 1px solid #dce7e2; border-radius: 1rem; background: #f5f8f7; }
|
||||
.issue-summary-grid span, .issue-summary-grid small { color: #71858d; font-size: .72rem; }
|
||||
.issue-summary-grid strong { color: #234957; font-size: 1.3rem; }
|
||||
.issue-summary-grid .issue-gate-pass { border-color: #b8ddcd; background: #f1faf6; }
|
||||
.issue-summary-grid .issue-gate-blocked { border-color: #efc3bd; background: #fff5f3; }
|
||||
.issue-summary-grid .issue-gate-blocked strong { color: #a43f35; }
|
||||
.integration-issue-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); align-items: end; gap: .7rem; padding: 1rem; border: 1px solid #d9e6e7; border-radius: 1rem; background: #f5f9fa; }
|
||||
.integration-issue-form label { display: grid; gap: .35rem; color: #657982; font-size: .72rem; font-weight: 700; }
|
||||
.integration-issue-form label:first-child { grid-column: 1 / -1; }
|
||||
.integration-issue-form fieldset { min-width: 0; margin: 0; padding: .75rem; border: 1px solid #d9e6e7; border-radius: .7rem; }
|
||||
.integration-issue-form .checkbox-label { display: flex; align-items: center; gap: .45rem; min-width: 0; overflow-wrap: anywhere; }
|
||||
.integration-issue-form .checkbox-label input { flex: 0 0 auto; width: auto; }
|
||||
.integration-issue-form textarea { min-height: 5rem; resize: vertical; }
|
||||
.integration-issue-form button { justify-self: end; }
|
||||
.integration-issue-card > p { margin: .7rem 0 0; color: #506c77; font-size: .76rem; line-height: 1.6; white-space: pre-wrap; }
|
||||
.alert-summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: .8rem; margin: 1rem 0; }
|
||||
.alert-summary-grid article { display: grid; gap: .25rem; padding: 1rem; border: 1px solid #dce7e2; border-radius: 1rem; background: #f5f8f7; }
|
||||
.alert-summary-grid span, .alert-summary-grid small { color: #71858d; font-size: .72rem; }
|
||||
.alert-summary-grid strong { color: #234957; font-size: 1.35rem; }
|
||||
.alert-queue-layout { display: grid; grid-template-columns: minmax(20rem,.85fr) minmax(25rem,1.15fr); min-height: 28rem; overflow: hidden; border: 1px solid #dce7e2; border-radius: 1rem; background: #fff; }
|
||||
.alert-queue { border-right: 1px solid #e2e9e6; }
|
||||
.alert-queue__toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; border-bottom: 1px solid #e6ece9; background: #f4f8f6; }
|
||||
.alert-queue__toolbar label { display: grid; gap: .2rem; color: #71858d; font-size: .68rem; }
|
||||
.alert-queue__toolbar select { min-width: 8rem; padding: .45rem .55rem; font-size: .72rem; }
|
||||
.alert-queue > p { margin: 0; padding: 1rem; color: #71858d; font-size: .74rem; }
|
||||
.alert-queue ul { max-height: 35rem; overflow: auto; margin: 0; padding: 0; list-style: none; }
|
||||
.alert-queue li { border-bottom: 1px solid #edf1ef; }
|
||||
.alert-queue li.selected { box-shadow: inset .22rem 0 #2d725b; background: #f3f8f5; }
|
||||
.alert-queue li button { display: grid; grid-template-columns: auto minmax(0,1fr); width: 100%; gap: .7rem; padding: .8rem 1rem; border: 0; border-radius: 0; color: inherit; background: transparent; text-align: left; box-shadow: none; }
|
||||
.alert-queue li button > span:last-child { display: grid; gap: .2rem; min-width: 0; }
|
||||
.alert-queue li button strong { overflow: hidden; color: #31515e; font-size: .77rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.alert-queue li button small { color: #72858d; font-size: .68rem; line-height: 1.45; }
|
||||
.alert-severity { align-self: start; min-width: 2.2rem; padding: .25rem .4rem; border-radius: 999px; color: #fff; font-size: .64rem; font-weight: 800; text-align: center; }
|
||||
.alert-severity--critical { background: #a53b3b; }
|
||||
.alert-severity--high { background: #c56a32; }
|
||||
.alert-severity--medium { background: #aa882b; }
|
||||
.alert-severity--low { background: #447b72; }
|
||||
.alert-detail { min-width: 0; padding: 1.2rem; }
|
||||
.alert-detail__title { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
|
||||
.alert-detail__title span { color: #7a8b92; font-family: "Cascadia Mono",Consolas,monospace; font-size: .68rem; }
|
||||
.alert-detail__title h3 { margin: .25rem 0 0; color: #294a57; }
|
||||
.alert-detail__title b { padding: .3rem .6rem; border-radius: 999px; color: #2d725b; background: #e7f2ed; font-size: .7rem; white-space: nowrap; }
|
||||
.alert-detail > p, .alert-detail__empty p { color: #657b84; font-size: .76rem; line-height: 1.65; }
|
||||
.alert-detail dl { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .45rem .8rem; margin: 1rem 0; }
|
||||
.alert-detail dl div { min-width: 0; padding: .65rem; border-radius: .7rem; background: #f5f8f7; }
|
||||
.alert-detail dt { color: #7b8c93; font-size: .65rem; }
|
||||
.alert-detail dd { overflow-wrap: anywhere; margin: .25rem 0 0; color: #3f5d68; font-size: .72rem; }
|
||||
.alert-detail__actions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
.alert-detail__empty { display: grid; place-content: center; min-height: 24rem; text-align: center; }
|
||||
.alert-timeline { margin-top: 1.2rem; padding-top: 1rem; border-top: 1px solid #e3ebe7; }
|
||||
.alert-timeline > p { color: #71858d; font-size: .72rem; }
|
||||
.alert-timeline ol { position: relative; margin: .8rem 0 0; padding: 0 0 0 1.4rem; list-style: none; }
|
||||
.alert-timeline ol::before { position: absolute; top: .3rem; bottom: .3rem; left: .35rem; width: 1px; content: ""; background: #ccdcd5; }
|
||||
.alert-timeline li { position: relative; margin-bottom: .8rem; }
|
||||
.alert-timeline li::before { position: absolute; top: .3rem; left: -1.35rem; width: .58rem; height: .58rem; border: .18rem solid #fff; border-radius: 50%; content: ""; background: #3f806b; box-shadow: 0 0 0 1px #b9cec5; }
|
||||
.alert-timeline li span { color: #2f5a68; font-size: .72rem; font-weight: 800; }
|
||||
.alert-timeline li p { margin: .18rem 0; color: #607781; font-size: .71rem; }
|
||||
.alert-timeline li small { color: #839198; font-size: .65rem; }
|
||||
.alert-config-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .8rem; margin: 1rem 0; }
|
||||
.alert-config-form { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); align-content: start; align-items: end; gap: .7rem; padding: 1rem; border: 1px solid #dce7e2; border-radius: 1rem; background: #f7faf8; }
|
||||
.alert-config-form > div, .alert-config-form__wide { grid-column: 1 / -1; }
|
||||
.alert-config-form > div { display: grid; gap: .2rem; }
|
||||
.alert-config-form > div span { color: #71858d; font-size: .72rem; }
|
||||
.alert-config-form label { display: grid; gap: .3rem; color: #657982; font-size: .72rem; font-weight: 700; }
|
||||
.alert-config-form button { justify-self: end; }
|
||||
.alert-config-form--silence { align-content: start; border-color: #e4dfd2; background: #faf9f5; }
|
||||
.alert-ledger-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .8rem; }
|
||||
.alert-ledger-grid > section { overflow: hidden; border: 1px solid #dce7e2; border-radius: 1rem; background: #fff; }
|
||||
.alert-ledger-grid section > div { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 1rem; border-bottom: 1px solid #e6ece9; background: #f4f8f6; }
|
||||
.alert-ledger-grid section > div span { color: #71858d; font-size: .72rem; }
|
||||
.alert-ledger-grid ul { max-height: 18rem; overflow: auto; margin: 0; padding: 0; list-style: none; }
|
||||
.alert-ledger-grid li { display: flex; align-items: center; justify-content: space-between; gap: .8rem; padding: .75rem 1rem; border-bottom: 1px solid #edf1ef; }
|
||||
.alert-ledger-grid li > span { display: grid; min-width: 0; gap: .2rem; }
|
||||
.alert-ledger-grid li strong { color: #3a5965; font-size: .72rem; }
|
||||
.alert-ledger-grid li small, .alert-ledger-grid li b { overflow-wrap: anywhere; color: #71858d; font-size: .68rem; font-weight: 400; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
main { padding: 3rem 0; }
|
||||
.hero { align-items: start; flex-direction: column; gap: 1.5rem; }
|
||||
.status-grid { grid-template-columns: 1fr; }
|
||||
.identity-panel { grid-template-columns: 1fr; gap: 2rem; }
|
||||
.membership { align-items: start; flex-direction: column; gap: 0.25rem; }
|
||||
.workspace-shell { padding: 0.7rem; }
|
||||
.workspace-shell__heading { align-items: start; flex-direction: column; gap: 0.7rem; padding: 0.65rem; }
|
||||
.workspace-shell__heading > p { text-align: left; }
|
||||
.workspace-nav { display: flex; overflow-x: auto; scroll-snap-type: x proximity; }
|
||||
.workspace-nav__item { min-width: 10.5rem; scroll-snap-align: start; }
|
||||
.milestone-panel { grid-template-columns: 1fr; gap: 2rem; }
|
||||
.milestone-panel ol { grid-template-columns: repeat(2, 1fr); }
|
||||
.workspace { padding: 1.2rem; }
|
||||
.workspace__header { align-items: stretch; flex-direction: column; }
|
||||
.workspace__actions { align-items: stretch; flex-direction: column; }
|
||||
.accept-invitation { align-items: stretch; flex-direction: column; }
|
||||
.accept-invitation label { width: 100%; }
|
||||
.tool-form { grid-template-columns: 1fr; align-items: stretch; }
|
||||
.subject-create-form, .profile-editor { grid-template-columns: 1fr; }
|
||||
.subject-create-form > div:first-child, .subject-create-form .tool-form__wide, .profile-editor .section-title, .profile-editor__wide { grid-column: auto; }
|
||||
.invitation-secret, .member-columns { grid-template-columns: 1fr; }
|
||||
.member-row { align-items: start; flex-direction: column; }
|
||||
.workspace__summary, .detail-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.conversation-readiness__head { flex-direction: column; }
|
||||
.conversation-readiness__checks { grid-template-columns: 1fr; }
|
||||
.readiness-check { grid-template-columns: auto minmax(0,1fr); }
|
||||
.readiness-check button { grid-column: 2; justify-self: start; }
|
||||
.story-source-actions { justify-content: flex-start; }
|
||||
.workspace__split, .conversation-layout, .story-layout { grid-template-columns: 1fr; }
|
||||
.resource-list { display: flex; overflow-x: auto; gap: 0.4rem; }
|
||||
.resource-list__title { display: none; }
|
||||
.resource-button { min-width: 10rem; margin: 0; }
|
||||
.section-columns, .device-grid { grid-template-columns: 1fr; }
|
||||
.firmware-tools .tool-form, .firmware-columns, .firmware-assignment, .firmware-impact__metrics, .firmware-impact__devices article { grid-template-columns: 1fr; }
|
||||
.firmware-event-timeline li { grid-template-columns: 1fr; gap: 0.2rem; }
|
||||
.firmware-tools .tool-form__wide, .firmware-digest, .firmware-device-select { grid-column: auto; }
|
||||
.firmware-detail__actions { align-items: stretch; flex-direction: column; }
|
||||
.firmware-detail__actions label { margin-right: 0; }
|
||||
.firmware-impact__devices > article > div:last-child { text-align: left; }
|
||||
.recording-grant-form, .recording-segment { grid-template-columns: 1fr; }
|
||||
.recording-grant-form > div { grid-column: 1 / -1; }
|
||||
.recording-capabilities small { text-align: left; }
|
||||
.conversation-list { border-right: 0; border-bottom: 1px solid #dfe8e4; }
|
||||
.conversation-filter__fields { grid-template-columns: 1fr; }
|
||||
.conversation-filter label:first-child { grid-column: auto; }
|
||||
.story-sidebar { border-right: 0; border-bottom: 1px solid #dfe8e4; }
|
||||
.privacy-toolbar { grid-template-columns: 1fr; align-items: stretch; }
|
||||
.privacy-counts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.privacy-delete-confirmation { grid-template-columns: 1fr; }
|
||||
.privacy-delete-confirmation__head, .privacy-delete-confirmation > p, .privacy-delete-confirmation__error, .privacy-delete-confirmation__actions { grid-column: auto; }
|
||||
.ops-workspace { padding: 1.2rem; }
|
||||
.ops-workspace__head { align-items: stretch; flex-direction: column; }
|
||||
.ops-workspace__head label { min-width: 0; }
|
||||
.ops-config-form, .ops-bundle-composer, .ops-version, .ops-gate-grid, .ops-rollout-panel, .ops-ledger-grid, .billing-summary-grid, .billing-form, .billing-ledger-grid, .alert-summary-grid, .alert-queue-layout, .alert-config-grid, .alert-ledger-grid, .diagnostic-search, .diagnostic-card dl, .integration-issue-form, .external-dependency-grid, .issue-summary-grid, .sensitive-access-request { grid-template-columns: 1fr; }
|
||||
.sensitive-access-request__head, .sensitive-access-request > p, .sensitive-access-request > label:first-of-type, .sensitive-access-request__actions { grid-column: auto; }
|
||||
.ops-config-form__intro, .ops-config-form__wide { grid-column: auto; }
|
||||
.ops-rollout-panel > div, .ops-rollout-panel__reason, .ops-evidence-list { grid-column: auto; }
|
||||
.ops-config-form button { justify-self: stretch; }
|
||||
.ops-diff { grid-template-columns: 1fr; }
|
||||
.ops-version__actions { justify-content: flex-start; }
|
||||
.billing-form__intro, .billing-form__wide { grid-column: auto; }
|
||||
.billing-form button { justify-self: stretch; }
|
||||
.billing-rate-table > div, .billing-rate-table label { min-width: 38rem; }
|
||||
.billing-policy-list li { align-items: flex-start; flex-direction: column; }
|
||||
.conversation-usage { grid-template-columns: 1fr; }
|
||||
.alert-queue { border-right: 0; border-bottom: 1px solid #e2e9e6; }
|
||||
.alert-detail dl { grid-template-columns: 1fr; }
|
||||
.alert-config-form { grid-template-columns: 1fr; }
|
||||
.alert-config-form > div, .alert-config-form__wide { grid-column: auto; }
|
||||
.alert-config-form button { justify-self: stretch; }
|
||||
}
|
||||
Reference in New Issue
Block a user