This commit is contained in:
+61
-5
@@ -14,9 +14,15 @@ 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 OpsDevice = components["schemas"]["OpsDevice"];
|
||||
export type OpsDeviceAssignmentOption = components["schemas"]["OpsDeviceAssignmentOption"];
|
||||
export type DeviceCredentialIssue = components["schemas"]["DeviceCredentialIssue"];
|
||||
export type DeviceClaimIssue = components["schemas"]["DeviceClaimIssue"];
|
||||
export type DevicePresence = components["schemas"]["DevicePresence"];
|
||||
export type DeviceTwin = components["schemas"]["DeviceTwin"];
|
||||
export type DeviceCommand = components["schemas"]["DeviceCommand"];
|
||||
export type DeviceBindingImpact = components["schemas"]["DeviceBindingImpact"];
|
||||
export type DeviceBindingChangeResult = components["schemas"]["DeviceBindingChangeResult"];
|
||||
export type FirmwareSigningKey = components["schemas"]["FirmwareSigningKey"];
|
||||
export type FirmwareRelease = components["schemas"]["FirmwareRelease"];
|
||||
export type FirmwareReleaseImpact = components["schemas"]["FirmwareReleaseImpact"];
|
||||
@@ -33,6 +39,7 @@ 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 FactCandidate = components["schemas"]["FactCandidate"];
|
||||
export type CuratedMemory = components["schemas"]["CuratedMemory"];
|
||||
export type PrivacyRequest = components["schemas"]["PrivacyRequest"];
|
||||
export type ExportTask = components["schemas"]["ExportTask"];
|
||||
@@ -43,9 +50,12 @@ 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 ContentQualitySummary = components["schemas"]["ContentQualitySummary"];
|
||||
export type ContentErrorType = components["schemas"]["ContentErrorType"];
|
||||
export type BillingPriceBook = components["schemas"]["BillingPriceBook"];
|
||||
export type QuotaPolicy = components["schemas"]["QuotaPolicy"];
|
||||
export type CostSummary = components["schemas"]["CostSummary"];
|
||||
export type UsageOverview = components["schemas"]["UsageOverview"];
|
||||
export type AlertRoutingPolicy = components["schemas"]["AlertRoutingPolicy"];
|
||||
export type AlertDeliveryTest = components["schemas"]["AlertDeliveryTest"];
|
||||
export type AlertIncident = components["schemas"]["AlertIncident"];
|
||||
@@ -115,6 +125,22 @@ export const platformApi = {
|
||||
version: (signal?: AbortSignal) => getJSON<VersionResponse>("/api/v1/version", signal),
|
||||
currentUser: (accessToken: string, signal?: AbortSignal) =>
|
||||
getJSONWithToken<CurrentUserResponse>("/api/v1/me", accessToken, signal),
|
||||
opsDevices: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsDevice[] }>>("GET", "/api/v1/ops/devices", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
opsDeviceAssignmentOptions: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: OpsDeviceAssignmentOption[] }>>("GET", "/api/v1/ops/device-assignment-options", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createOpsDevice: (tenantID: string, input: components["schemas"]["CreateOpsDeviceRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<{ device: OpsDevice; credential: DeviceCredentialIssue; claim?: DeviceClaimIssue }>>("POST", "/api/v1/ops/devices", accessToken, undefined, input, { "X-Tenant-Id": tenantID }),
|
||||
updateOpsDevice: (tenantID: string, device: OpsDevice, input: components["schemas"]["UpdateOpsDeviceRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsDevice>>("PATCH", `/api/v1/ops/devices/${device.id}`, accessToken, undefined, input, { "X-Tenant-Id": tenantID, "If-Match": `"v${device.version}"` }),
|
||||
deleteOpsDevice: (tenantID: string, device: OpsDevice, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<OpsDevice>>("DELETE", `/api/v1/ops/devices/${device.id}`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID, "If-Match": `"v${device.version}"` }),
|
||||
rotateOpsDeviceCredential: (tenantID: string, deviceID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<DeviceCredentialIssue>>("POST", `/api/v1/ops/devices/${deviceID}/credentials/rotate`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
|
||||
issueOpsDeviceClaim: (tenantID: string, deviceID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<DeviceClaimIssue>>("POST", `/api/v1/ops/devices/${deviceID}/claims`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
|
||||
opsContentQuality: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: ContentQualitySummary[] }>>("GET", "/api/v1/ops/content-quality", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
opsConfigVersions: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
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) =>
|
||||
@@ -170,6 +196,8 @@ export const platformApi = {
|
||||
{ "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 }),
|
||||
opsUsageOverview: (tenantID: string, query: { from: string; to: string; timezone?: string; household_id?: string; device_id?: string }, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<UsageOverview>>("GET", `/api/v1/ops/usage-overview?${usageOverviewParameters(query)}`, 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) =>
|
||||
@@ -243,6 +271,9 @@ export const platformApi = {
|
||||
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 }),
|
||||
updateHousehold: (household: Household, name: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<Household>>("PATCH", `/api/v1/households/${household.id}`, accessToken, undefined,
|
||||
{ name }, { "If-Match": `"v${household.version}"` }),
|
||||
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) =>
|
||||
@@ -269,6 +300,8 @@ export const platformApi = {
|
||||
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),
|
||||
familyUsageOverview: (householdID: string, query: { from: string; to: string; timezone?: string; device_id?: string }, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<UsageOverview>>("GET", `/api/v1/households/${householdID}/usage-overview?${usageOverviewParameters(query)}`, 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) =>
|
||||
@@ -294,18 +327,21 @@ export const platformApi = {
|
||||
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) =>
|
||||
reviseStory: (story: Story, content: string, reason: string, accessToken: string, errorType?: ContentErrorType) =>
|
||||
authenticatedJSON<Envelope<Story>>("PATCH", `/api/v1/stories/${story.id}`, accessToken, undefined,
|
||||
{ content, reason }, { "If-Match": `"v${story.version}"` }),
|
||||
{ content, reason, error_type: errorType }, { "If-Match": `"v${story.version}"` }),
|
||||
updateFactCandidate: (storyID: string, fact: FactCandidate, input: components["schemas"]["UpdateFactCandidateRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<FactCandidate>>("PATCH", `/api/v1/stories/${storyID}/facts/${fact.id}`, accessToken, undefined,
|
||||
input, { "If-Match": `"v${fact.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) =>
|
||||
rejectStory: (story: Story, reason: string, accessToken: string, errorType?: ContentErrorType) =>
|
||||
authenticatedJSON<Envelope<Story>>("POST", `/api/v1/stories/${story.id}/reject`, accessToken, undefined,
|
||||
{ reason }, { "If-Match": `"v${story.version}"` }),
|
||||
{ reason, error_type: errorType }, { "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) =>
|
||||
updateMemory: (memory: CuratedMemory, input: components["schemas"]["UpdateMemoryRequest"], 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) =>
|
||||
@@ -318,6 +354,12 @@ export const platformApi = {
|
||||
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()}` }),
|
||||
createPrivacyCorrectionRequest: (subjectID: string, corrections: { preferred_name?: string; language?: string; timezone?: string; profile_data?: Record<string, unknown>; conversation_preferences?: Record<string, unknown> }, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<PrivacyRequest>>("POST", `/api/v1/subjects/${subjectID}/privacy-requests`, accessToken, undefined,
|
||||
{ request_type: "CORRECTION", scope: { corrections } }, { "Idempotency-Key": `privacy-correction-${crypto.randomUUID()}` }),
|
||||
createConsentWithdrawalRequest: (subjectID: string, consentTypes: components["schemas"]["ConsentType"][], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<PrivacyRequest>>("POST", `/api/v1/subjects/${subjectID}/privacy-requests`, accessToken, undefined,
|
||||
{ request_type: "CONSENT_WITHDRAWAL", scope: { consent_types: consentTypes } }, { "Idempotency-Key": `privacy-consent-withdrawal-${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()}` }),
|
||||
@@ -371,6 +413,13 @@ export const platformApi = {
|
||||
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),
|
||||
deviceBindingImpact: (deviceID: string, action: "unbind" | "transfer", targetSubjectID: string | undefined, accessToken: string, signal?: AbortSignal) => {
|
||||
const query = new URLSearchParams({ action });
|
||||
if (targetSubjectID) query.set("target_subject_id", targetSubjectID);
|
||||
return authenticatedJSON<Envelope<DeviceBindingImpact>>("GET", `/api/v1/devices/${deviceID}/binding-impact?${query}`, accessToken, signal);
|
||||
},
|
||||
changeDeviceBinding: (deviceID: string, input: components["schemas"]["ChangeDeviceBindingRequest"], accessToken: string) =>
|
||||
authenticatedJSON<Envelope<DeviceBindingChangeResult>>("POST", `/api/v1/devices/${deviceID}/binding-change`, 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}"` })
|
||||
@@ -412,6 +461,13 @@ export const platformApi = {
|
||||
{ rollback_release_id: rollbackReleaseID })
|
||||
};
|
||||
|
||||
function usageOverviewParameters(query: { from: string; to: string; timezone?: string; household_id?: string; device_id?: string }) {
|
||||
const parameters = new URLSearchParams({ from: query.from, to: query.to, timezone: query.timezone || "Asia/Shanghai" });
|
||||
if (query.household_id) parameters.set("household_id", query.household_id);
|
||||
if (query.device_id) parameters.set("device_id", query.device_id);
|
||||
return parameters.toString();
|
||||
}
|
||||
|
||||
async function getJSONWithToken<T>(path: string, accessToken: string, signal?: AbortSignal): Promise<T> {
|
||||
return authenticatedJSON<T>("GET", path, accessToken, signal);
|
||||
}
|
||||
|
||||
Vendored
+1060
-5
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,454 @@
|
||||
:root {
|
||||
--cal-primary: #111111;
|
||||
--cal-primary-active: #242424;
|
||||
--cal-ink: #111111;
|
||||
--cal-body: #374151;
|
||||
--cal-muted: #6b7280;
|
||||
--cal-muted-soft: #898989;
|
||||
--cal-hairline: #e5e7eb;
|
||||
--cal-hairline-soft: #f3f4f6;
|
||||
--cal-canvas: #ffffff;
|
||||
--cal-soft: #f8f9fa;
|
||||
--cal-card: #f5f5f5;
|
||||
--cal-dark: #101010;
|
||||
--console-primary: #111111;
|
||||
--console-primary-soft: #f5f5f5;
|
||||
--family-primary: var(--cal-primary);
|
||||
--family-primary-strong: var(--cal-primary-active);
|
||||
--family-text: var(--cal-ink);
|
||||
--family-muted: var(--cal-muted);
|
||||
--family-border: var(--cal-hairline);
|
||||
--family-border-strong: #d1d5db;
|
||||
--family-surface: var(--cal-canvas);
|
||||
--family-surface-soft: var(--cal-soft);
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--cal-ink);
|
||||
background: var(--cal-canvas);
|
||||
}
|
||||
|
||||
html, body, #root { min-height: 100%; background: var(--cal-canvas); }
|
||||
body { color: var(--cal-body); background: var(--cal-canvas) !important; font-family: inherit; font-size: 16px; line-height: 1.5; letter-spacing: 0; }
|
||||
button, input, select, textarea { font-family: inherit; }
|
||||
h1, h2, h3, h4, .ant-typography h1, .ant-typography h2, .ant-typography h3, .ant-typography h4 {
|
||||
color: var(--cal-ink) !important;
|
||||
font-family: "Cal Sans", Manrope, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: -.04em;
|
||||
}
|
||||
:where(button, a, input, select, textarea, [tabindex]):focus-visible { outline: 2px solid var(--cal-ink); outline-offset: 2px; }
|
||||
|
||||
/* Shared top navigation */
|
||||
.customer-header, .maintenance-header, .app-header {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
z-index: 110;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
height: 64px !important;
|
||||
padding: 0 clamp(24px, 3.2vw, 48px) !important;
|
||||
border: 0 !important;
|
||||
border-bottom: 1px solid var(--cal-hairline) !important;
|
||||
color: var(--cal-ink) !important;
|
||||
background: #fff !important;
|
||||
box-shadow: none !important;
|
||||
line-height: normal;
|
||||
}
|
||||
.customer-header__brand, .maintenance-header__brand, .app-header__brand { display: flex; align-items: center; gap: 10px; color: var(--cal-ink) !important; }
|
||||
.customer-header__brand > strong, .maintenance-header__brand > strong, .app-header__brand > strong { color: var(--cal-ink) !important; font-size: 18px; letter-spacing: -.025em; }
|
||||
.customer-brand-mark, .maintenance-brand-mark, .app-brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
border-radius: 0 !important;
|
||||
color: var(--cal-ink) !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 25px;
|
||||
}
|
||||
.maintenance-brand-divider, .customer-brand-divider, .app-brand-divider { width: 1px; height: 18px; background: var(--cal-hairline) !important; }
|
||||
.customer-menu-toggle, .maintenance-menu-toggle { display: none !important; }
|
||||
.customer-top-nav, .maintenance-top-nav { display: flex; align-self: stretch; justify-content: flex-start; gap: clamp(16px, 2.2vw, 38px); margin-left: clamp(36px, 5vw, 72px); }
|
||||
.customer-top-nav button, .maintenance-top-nav button {
|
||||
position: relative;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
color: var(--cal-body) !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
.customer-top-nav button::after, .maintenance-top-nav button::after { position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: transparent; content: ""; }
|
||||
.customer-top-nav button.active, .maintenance-top-nav button.active { color: var(--cal-ink) !important; font-weight: 600 !important; }
|
||||
.customer-top-nav button.active::after, .maintenance-top-nav button.active::after { background: var(--cal-ink); }
|
||||
.customer-header__actions, .maintenance-header__actions, .app-header__tools { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
|
||||
.customer-header .ant-btn, .maintenance-header .ant-btn, .app-header .ant-btn { color: var(--cal-ink) !important; background: transparent !important; }
|
||||
.customer-header .ant-btn:hover, .maintenance-header .ant-btn:hover, .app-header .ant-btn:hover { color: var(--cal-ink) !important; background: var(--cal-soft) !important; }
|
||||
.customer-header .ant-avatar, .maintenance-header .ant-avatar, .app-header .ant-avatar { color: var(--cal-ink); background: #f4dddd; }
|
||||
.customer-search, .maintenance-search { width: min(330px, 25vw) !important; }
|
||||
.customer-search .ant-input-affix-wrapper, .customer-search .ant-input-group-addon .ant-btn, .maintenance-search.ant-input-affix-wrapper {
|
||||
height: 40px !important;
|
||||
border: 1px solid var(--cal-hairline) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cal-body) !important;
|
||||
background: #fff !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.customer-search input, .maintenance-search input { color: var(--cal-body) !important; background: transparent !important; }
|
||||
.customer-search input::placeholder, .maintenance-search input::placeholder { color: #9ca3af !important; }
|
||||
|
||||
.customer-layout, .maintenance-layout, .app-layout, .customer-layout__body, .maintenance-layout__body, .app-layout__body { min-height: 100vh; background: #fff !important; }
|
||||
.customer-layout__body, .maintenance-layout__body, .app-layout__body { padding-top: 64px !important; }
|
||||
.customer-sider, .maintenance-sider, .app-sider { display: none !important; }
|
||||
.customer-content, .maintenance-content, .app-content { min-width: 0; background: #fff !important; }
|
||||
.customer-page, .maintenance-page, .app-page {
|
||||
width: min(1200px, 100%) !important;
|
||||
margin: 0 auto !important;
|
||||
padding: 44px 24px 96px !important;
|
||||
}
|
||||
.customer-page--home { width: 100% !important; max-width: 1440px !important; padding-inline: 46px !important; }
|
||||
.customer-page__header, .maintenance-page__header, .app-page__header { margin: 12px 0 32px !important; }
|
||||
.customer-page__header h1, .maintenance-page__header h1, .app-page__header h1 { font-size: 36px !important; line-height: 1.15 !important; }
|
||||
.customer-page__header p, .maintenance-page__header p, .app-page__header p { margin-top: 8px !important; color: var(--cal-muted) !important; font-size: 15px !important; }
|
||||
.maintenance-page__icon, .app-page__icon { border-radius: 8px !important; color: var(--cal-ink) !important; background: var(--cal-card) !important; box-shadow: none !important; }
|
||||
.ant-breadcrumb { color: var(--cal-muted); font-size: 13px; }
|
||||
|
||||
/* Universal product surfaces */
|
||||
.ant-card, .workspace, .ops-workspace, .device-list-panel, .family-member-card, .family-home-card, .family-activity-card,
|
||||
.subject-profile-card, .story-candidate-card, .confirmed-memory-card, .panel-section, .tool-form, .profile-editor,
|
||||
.conversation-readiness, .privacy-card, .message-item, .workspace-dialog {
|
||||
border: 1px solid var(--cal-hairline) !important;
|
||||
border-radius: 12px !important;
|
||||
color: var(--cal-body) !important;
|
||||
background: #fff !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.workspace, .ops-workspace, .customer-page > .workspace { padding: 32px !important; }
|
||||
.workspace__header, .ops-workspace__head { padding-bottom: 24px; border-bottom: 1px solid var(--cal-hairline-soft) !important; }
|
||||
.workspace__header h2, .ops-workspace__head h2 { font-size: 28px !important; line-height: 1.2 !important; }
|
||||
.eyebrow { color: var(--cal-muted) !important; font-size: 12px !important; font-weight: 600 !important; letter-spacing: .08em !important; }
|
||||
.metric, .summary-card, .info, .readiness-check, .device-card__facts > div, .billing-total-grid article,
|
||||
.customer-quick-grid button, .customer-action-list > button { border: 0 !important; border-radius: 12px !important; background: var(--cal-card) !important; box-shadow: none !important; }
|
||||
.tool-form, .profile-editor, .conversation-readiness, .workspace-dialog__body { background: var(--cal-soft) !important; }
|
||||
.workspace__tabs { padding: 6px !important; border-radius: 9999px !important; background: var(--cal-soft) !important; }
|
||||
.tab-button { min-height: 36px !important; border-radius: 8px !important; color: var(--cal-muted) !important; background: transparent !important; }
|
||||
.tab-button.active { color: var(--cal-ink) !important; background: #fff !important; box-shadow: 0 1px 2px rgba(0,0,0,.05) !important; }
|
||||
|
||||
button:not(.ant-btn) { min-height: 40px; border-radius: 8px !important; font-size: 14px; font-weight: 600; }
|
||||
button:where(:not(.ant-btn):not(.button-secondary):not(.button-danger):not(.tab-button):not(.resource-button):not(.conversation-row):not(.story-button):not(.subject-profile-card):not(.story-candidate-card):not(.family-member-card):not(.family-icon-button)),
|
||||
.ant-btn-primary { border-color: var(--cal-primary) !important; color: #fff !important; background: var(--cal-primary) !important; box-shadow: none !important; }
|
||||
button:not(.ant-btn):hover, .ant-btn-primary:hover { transform: none !important; }
|
||||
.button-secondary, .ant-btn-default { border: 1px solid var(--cal-hairline) !important; color: var(--cal-ink) !important; background: #fff !important; box-shadow: none !important; }
|
||||
.button-secondary:hover, .ant-btn-default:hover { border-color: var(--cal-ink) !important; color: var(--cal-ink) !important; background: #fff !important; }
|
||||
.button-small { min-height: 32px !important; padding: 6px 12px !important; border-radius: 6px !important; }
|
||||
.button-danger { border-color: #fecaca !important; color: #dc2626 !important; background: #fff !important; }
|
||||
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea,
|
||||
.ant-input, .ant-input-affix-wrapper, .ant-select-selector { min-height: 40px !important; border: 1px solid var(--cal-hairline) !important; border-radius: 8px !important; color: var(--cal-ink) !important; background: #fff !important; box-shadow: none !important; }
|
||||
textarea { min-height: 96px !important; }
|
||||
input:focus, select:focus, textarea:focus, .ant-input-affix-wrapper:focus-within { border-color: var(--cal-ink) !important; box-shadow: 0 0 0 2px rgba(17,17,17,.08) !important; outline: 0; }
|
||||
.tag, .status, .permission, .event-stream-status, .ant-tag { border-radius: 9999px !important; }
|
||||
.workspace__action-message { color: #047857 !important; background: #ecfdf5 !important; }
|
||||
.workspace__inline-error { color: #dc2626 !important; background: #fef2f2 !important; }
|
||||
.workspace__readonly-note { color: var(--cal-muted) !important; background: var(--cal-soft) !important; }
|
||||
.conversation-readiness, .conversation-readiness--ready, .conversation-readiness--blocked { background-image: none !important; }
|
||||
.milestone-panel, .customer-welcome-card { border: 0 !important; border-radius: 12px !important; color: var(--cal-ink) !important; background: var(--cal-card) !important; box-shadow: none !important; }
|
||||
.milestone-panel h2, .customer-welcome-card h3 { color: var(--cal-ink) !important; }
|
||||
.milestone-panel p, .customer-welcome-card p { color: var(--cal-muted) !important; }
|
||||
.customer-page .customer-welcome-card > button {
|
||||
border-color: var(--cal-primary) !important;
|
||||
color: #fff !important;
|
||||
background: var(--cal-primary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.customer-page .customer-quick-grid button,
|
||||
.customer-page .customer-quick-grid button:hover,
|
||||
.customer-page .customer-quick-grid button:focus,
|
||||
.customer-page .customer-quick-grid button:focus-visible,
|
||||
.customer-page .customer-quick-grid button:active {
|
||||
border: 1px solid var(--cal-hairline) !important;
|
||||
color: var(--cal-body) !important;
|
||||
background: var(--cal-card) !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.customer-page .customer-quick-grid button:hover,
|
||||
.customer-page .customer-quick-grid button:focus-visible { border-color: #d1d5db !important; background: #eeeeef !important; }
|
||||
.customer-page .customer-quick-grid button:focus-visible { outline: 2px solid var(--cal-ink); outline-offset: 2px; }
|
||||
.customer-page .customer-quick-grid button span,
|
||||
.customer-page .customer-quick-grid button small { color: var(--cal-muted) !important; }
|
||||
.customer-page .customer-quick-grid button strong { color: var(--cal-ink) !important; }
|
||||
.workspace-dialog-backdrop { background: rgba(17,17,17,.42) !important; backdrop-filter: blur(5px); }
|
||||
|
||||
/* Monochrome interaction contract: blue is never used as click feedback. */
|
||||
a, a:visited { color: var(--cal-ink) !important; }
|
||||
a:hover { color: var(--cal-ink) !important; text-decoration: underline; }
|
||||
.customer-layout .ant-btn-primary,
|
||||
.maintenance-layout .ant-btn-primary,
|
||||
.app-layout .ant-btn-primary {
|
||||
border-color: var(--cal-primary) !important;
|
||||
color: #fff !important;
|
||||
background: var(--cal-primary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.customer-page .button-secondary,
|
||||
.maintenance-page .button-secondary,
|
||||
.app-page .button-secondary,
|
||||
.customer-layout .ant-btn-default,
|
||||
.maintenance-layout .ant-btn-default,
|
||||
.app-layout .ant-btn-default {
|
||||
border-color: var(--cal-hairline) !important;
|
||||
color: var(--cal-ink) !important;
|
||||
background: #fff !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.customer-page .button-secondary:hover,
|
||||
.maintenance-page .button-secondary:hover,
|
||||
.app-page .button-secondary:hover,
|
||||
.customer-layout .ant-btn-default:hover,
|
||||
.maintenance-layout .ant-btn-default:hover,
|
||||
.app-layout .ant-btn-default:hover { border-color: var(--cal-ink) !important; color: var(--cal-ink) !important; background: var(--cal-soft) !important; }
|
||||
|
||||
.customer-layout .ant-menu-item-selected,
|
||||
.customer-layout .ant-menu-submenu-selected > .ant-menu-submenu-title,
|
||||
.maintenance-layout .ant-menu-item-selected,
|
||||
.maintenance-layout .ant-menu-submenu-selected > .ant-menu-submenu-title,
|
||||
.app-layout .ant-menu-item-selected,
|
||||
.app-layout .ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||
color: var(--cal-ink) !important;
|
||||
background: var(--cal-card) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.customer-layout .ant-menu-item:hover,
|
||||
.customer-layout .ant-menu-submenu-title:hover,
|
||||
.maintenance-layout .ant-menu-item:hover,
|
||||
.maintenance-layout .ant-menu-submenu-title:hover,
|
||||
.app-layout .ant-menu-item:hover,
|
||||
.app-layout .ant-menu-submenu-title:hover { color: var(--cal-ink) !important; background: var(--cal-soft) !important; }
|
||||
|
||||
.customer-page .workspace__tabs .tab-button,
|
||||
.maintenance-page .workspace__tabs .tab-button,
|
||||
.app-page .workspace__tabs .tab-button { border-color: transparent !important; color: var(--cal-muted) !important; background: transparent !important; }
|
||||
.customer-page .workspace__tabs .tab-button:hover,
|
||||
.customer-page .workspace__tabs .tab-button.active,
|
||||
.maintenance-page .workspace__tabs .tab-button:hover,
|
||||
.maintenance-page .workspace__tabs .tab-button.active,
|
||||
.app-page .workspace__tabs .tab-button:hover,
|
||||
.app-page .workspace__tabs .tab-button.active { color: var(--cal-ink) !important; background: #fff !important; box-shadow: 0 1px 2px rgba(0,0,0,.05) !important; }
|
||||
.ant-tabs-tab:hover, .ant-tabs-tab-active .ant-tabs-tab-btn { color: var(--cal-ink) !important; }
|
||||
.ant-tabs-ink-bar { background: var(--cal-ink) !important; }
|
||||
|
||||
.customer-page .resource-button:hover,
|
||||
.customer-page .conversation-row:hover,
|
||||
.customer-page .story-button:hover,
|
||||
.customer-page .subject-profile-card:hover,
|
||||
.customer-page .story-candidate-card:hover { border-color: #d1d5db !important; color: var(--cal-ink) !important; background: var(--cal-soft) !important; box-shadow: none !important; }
|
||||
.customer-page .resource-button.active,
|
||||
.customer-page .conversation-row.active,
|
||||
.customer-page .story-button.active,
|
||||
.customer-page .subject-profile-card.active,
|
||||
.customer-page .story-candidate-card.active,
|
||||
.maintenance-page .alert-queue li.selected {
|
||||
border-color: var(--cal-ink) !important;
|
||||
color: var(--cal-ink) !important;
|
||||
background: var(--cal-card) !important;
|
||||
box-shadow: inset 3px 0 var(--cal-ink) !important;
|
||||
}
|
||||
|
||||
.ant-checkbox-checked .ant-checkbox-inner,
|
||||
.ant-radio-checked .ant-radio-inner,
|
||||
.ant-switch.ant-switch-checked { border-color: var(--cal-ink) !important; background: var(--cal-ink) !important; }
|
||||
.ant-radio-checked .ant-radio-inner::after { background: #fff !important; }
|
||||
.ant-slider .ant-slider-track { background: var(--cal-ink) !important; }
|
||||
.ant-slider .ant-slider-handle::after { box-shadow: 0 0 0 2px var(--cal-ink) !important; }
|
||||
input[type="checkbox"], input[type="radio"], input[type="range"] { accent-color: var(--cal-ink) !important; }
|
||||
|
||||
.customer-page input:focus,
|
||||
.customer-page select:focus,
|
||||
.customer-page textarea:focus,
|
||||
.maintenance-page input:focus,
|
||||
.maintenance-page select:focus,
|
||||
.maintenance-page textarea:focus,
|
||||
.app-page input:focus,
|
||||
.app-page select:focus,
|
||||
.app-page textarea:focus,
|
||||
.customer-search .ant-input-affix-wrapper:focus-within,
|
||||
.maintenance-search.ant-input-affix-wrapper:focus-within {
|
||||
border-color: var(--cal-ink) !important;
|
||||
box-shadow: 0 0 0 2px rgba(17,17,17,.08) !important;
|
||||
}
|
||||
|
||||
.family-icon-button,
|
||||
.subject-section-icon,
|
||||
.family-activity-card article > svg,
|
||||
.customer-login-icon,
|
||||
.maintenance-page__icon,
|
||||
.maintenance-login > .ant-card-body > .anticon,
|
||||
.quality-state > .anticon {
|
||||
color: var(--cal-ink) !important;
|
||||
background: var(--cal-card) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.family-role,
|
||||
.device-card__identity h3 span,
|
||||
.customer-profile .ant-tag { border-color: transparent !important; color: var(--cal-body) !important; background: var(--cal-card) !important; }
|
||||
.family-member-menu summary:hover,
|
||||
.family-member-card--invite:hover,
|
||||
.family-home-card footer button,
|
||||
.family-activity-card__more { border-color: var(--cal-ink) !important; color: var(--cal-ink) !important; background: var(--cal-soft) !important; box-shadow: none !important; }
|
||||
.quota-note { border-left-color: var(--cal-ink) !important; background: var(--cal-soft) !important; }
|
||||
.customer-page .message-item > span { background: var(--cal-ink) !important; }
|
||||
.maintenance-role-list article span,
|
||||
.sider-product .anticon { color: var(--cal-ink) !important; }
|
||||
|
||||
/* Restore the original customer navigation as the primary desktop menu. */
|
||||
@media (min-width: 901px) {
|
||||
.customer-top-nav { display: none !important; }
|
||||
.customer-menu-toggle { display: inline-flex !important; }
|
||||
.customer-header__actions { grid-column: 3; }
|
||||
.customer-sider {
|
||||
position: sticky !important;
|
||||
top: 64px !important;
|
||||
z-index: 80;
|
||||
display: block !important;
|
||||
height: calc(100vh - 64px) !important;
|
||||
overflow: auto;
|
||||
border-right: 1px solid var(--cal-hairline) !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
.customer-sider .ant-layout-sider-children { display: flex; min-height: 100%; flex-direction: column; }
|
||||
.customer-sider .ant-menu { flex: 1; padding: 0 10px 16px; border-inline-end: 0 !important; background: transparent !important; }
|
||||
.customer-sider .ant-menu-item,
|
||||
.customer-sider .ant-menu-submenu-title { min-height: 42px; margin-block: 3px; border-radius: 8px; line-height: 42px; }
|
||||
.customer-sider .ant-menu-item-divider { margin: 10px 0; border-color: var(--cal-hairline); }
|
||||
}
|
||||
|
||||
/* Selected family-home direction */
|
||||
.cal-family-home { color: var(--cal-body); }
|
||||
.cal-family-home__intro { margin-bottom: 28px; }
|
||||
.cal-family-home__intro h1 { margin: 0; font-size: 36px; line-height: 1.15; }
|
||||
.cal-family-home__intro p { margin: 6px 0 0; color: var(--cal-muted); font-size: 15px; }
|
||||
.cal-person-switcher { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }
|
||||
.cal-person-switcher button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 52px;
|
||||
padding: 7px 16px !important;
|
||||
border: 1px solid var(--cal-hairline) !important;
|
||||
border-radius: 8px !important;
|
||||
color: var(--cal-body) !important;
|
||||
background: #fff !important;
|
||||
box-shadow: none !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
.cal-person-switcher button.active { border-color: var(--cal-ink) !important; color: var(--cal-ink) !important; }
|
||||
.cal-person-switcher img { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; }
|
||||
.cal-person-switcher strong { color: var(--cal-ink); font-size: 14px; }
|
||||
.cal-person-switcher span { color: var(--cal-body); }
|
||||
.cal-person-switcher .anticon { margin-left: 8px; }
|
||||
.cal-companion-panel { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(320px, 1fr); overflow: hidden; border: 1px solid var(--cal-hairline); border-radius: 16px; background: #fff; }
|
||||
.cal-day-schedule { padding: 28px 32px 0; }
|
||||
.cal-day-schedule h2, .cal-elder-summary h2, .cal-recent h2 { margin: 0; font-family: Inter, sans-serif !important; font-size: 20px; letter-spacing: -.02em; }
|
||||
.cal-day-schedule ol { margin: 18px 0 0; padding: 0; list-style: none; }
|
||||
.cal-day-schedule li { position: relative; display: grid; grid-template-columns: 58px 14px 48px minmax(0,1fr) auto; align-items: center; min-height: 86px; border-bottom: 1px solid var(--cal-hairline-soft); }
|
||||
.cal-day-schedule li::before { position: absolute; top: 0; bottom: 0; left: 64px; border-left: 1px dashed #d1d5db; content: ""; }
|
||||
.cal-day-schedule li:first-child::before { top: 50%; }
|
||||
.cal-day-schedule li:last-child::before { bottom: 50%; }
|
||||
.cal-day-schedule time { color: var(--cal-muted); font-size: 14px; }
|
||||
.cal-timeline-dot { z-index: 1; width: 8px; height: 8px; border: 2px solid #fff; border-radius: 50%; background: #d1d5db; box-shadow: 0 0 0 1px #d1d5db; }
|
||||
.cal-day-schedule li.done .cal-timeline-dot { background: #9ca3af; }
|
||||
.cal-event-icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 8px; color: var(--cal-ink); background: var(--cal-card); font-size: 17px; }
|
||||
.cal-event-icon--warm { background: #fcede8; }
|
||||
.cal-day-schedule li div strong { display: block; color: var(--cal-ink); font-size: 15px; }
|
||||
.cal-day-schedule li div p { margin: 3px 0 0; color: var(--cal-muted); font-size: 13px; }
|
||||
.cal-day-schedule li > small { color: var(--cal-muted); font-size: 13px; }
|
||||
.cal-day-schedule footer { display: flex; align-items: center; gap: 8px; padding: 20px 0; color: var(--cal-muted); font-size: 12px; }
|
||||
.cal-elder-summary { padding: 28px 26px 24px; border-left: 1px solid var(--cal-hairline); }
|
||||
.cal-elder-summary > header { display: flex; align-items: center; gap: 14px; }
|
||||
.cal-elder-summary > header > img { width: 76px; height: 76px; border-radius: 50%; object-fit: cover; }
|
||||
.cal-elder-summary h2 { font-size: 22px; }
|
||||
.cal-elder-summary h2 span { margin-left: 4px; padding: 2px 7px; border-radius: 9999px; color: var(--cal-muted); background: var(--cal-card); font-family: Inter, sans-serif; font-size: 11px; font-weight: 500; letter-spacing: 0; }
|
||||
.cal-elder-summary header p { margin: 3px 0 0; color: var(--cal-muted); font-size: 13px; }
|
||||
.cal-online { display: flex; align-items: center; gap: 7px; margin: 18px 0 0; color: var(--cal-ink); font-size: 14px; font-weight: 600; }
|
||||
.cal-online span { width: 8px; height: 8px; border-radius: 50%; background: #34d399; }
|
||||
.cal-muted { margin: 3px 0 14px; color: var(--cal-muted); font-size: 13px; }
|
||||
.cal-device-row { display: grid; grid-template-columns: 40px minmax(0,1fr) auto; align-items: center; gap: 12px; padding: 14px 0; border-top: 1px solid var(--cal-hairline); border-bottom: 1px solid var(--cal-hairline); }
|
||||
.cal-device-row strong, .cal-device-row p { display: block; margin: 0; }
|
||||
.cal-device-row > img { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--cal-card); }
|
||||
.cal-device-row strong { color: var(--cal-ink); font-size: 14px; }
|
||||
.cal-device-row p, .cal-device-row small { color: var(--cal-muted); font-size: 12px; }
|
||||
.cal-latest-call { padding: 16px 0 12px; }
|
||||
.cal-latest-call > strong { color: var(--cal-ink); font-size: 13px; }
|
||||
.cal-latest-call > button { display: grid; grid-template-columns: 40px minmax(0,1fr) auto; align-items: center; gap: 12px; width: 100%; min-height: 0; margin-top: 8px; padding: 0 !important; color: var(--cal-body) !important; background: transparent !important; box-shadow: none !important; text-align: left; font-size: 12px; font-weight: 400; }
|
||||
.cal-latest-call > button small { display: block; margin-top: 4px; color: var(--cal-muted); }
|
||||
.cal-primary-action { width: 100%; border-radius: 8px !important; }
|
||||
.cal-recent { margin-top: 20px; padding: 20px 24px 8px; border: 1px solid var(--cal-hairline); border-radius: 12px; background: var(--cal-soft); }
|
||||
.cal-recent > header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 10px; }
|
||||
.cal-recent > header button { min-height: 34px; padding: 7px 12px !important; border: 1px solid var(--cal-hairline) !important; color: var(--cal-ink) !important; background: #fff !important; font-size: 12px; }
|
||||
.cal-recent > div { border: 1px solid var(--cal-hairline); border-radius: 8px; background: #fff; }
|
||||
.cal-recent > div > button { display: grid; grid-template-columns: 28px 120px minmax(0,1fr) 110px 12px; align-items: center; gap: 12px; width: 100%; min-height: 48px; padding: 0 14px !important; border: 0 !important; border-bottom: 1px solid var(--cal-hairline-soft) !important; border-radius: 0 !important; color: var(--cal-body) !important; background: transparent !important; box-shadow: none !important; text-align: left; font-weight: 400; }
|
||||
.cal-recent > div > button:last-child { border-bottom: 0 !important; }
|
||||
.cal-recent strong { color: var(--cal-ink); font-size: 13px; }
|
||||
.cal-recent span, .cal-recent time { color: var(--cal-muted); font-size: 12px; }
|
||||
.cal-recent time { text-align: right; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.customer-top-nav, .maintenance-top-nav { gap: 18px; }
|
||||
.customer-search, .maintenance-search { display: none; }
|
||||
.cal-companion-panel { grid-template-columns: 1.4fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.customer-header, .maintenance-header, .app-header { grid-template-columns: 1fr auto; padding-inline: 16px !important; }
|
||||
.customer-menu-toggle, .maintenance-menu-toggle { display: inline-flex !important; }
|
||||
.customer-top-nav, .maintenance-top-nav { display: none; }
|
||||
.customer-sider, .maintenance-sider, .app-sider {
|
||||
position: fixed !important;
|
||||
top: 64px !important;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
display: block !important;
|
||||
width: 224px !important;
|
||||
min-width: 224px !important;
|
||||
max-width: 224px !important;
|
||||
height: calc(100dvh - 64px) !important;
|
||||
border-right: 1px solid var(--cal-hairline) !important;
|
||||
background: #fff !important;
|
||||
transform: translateX(0);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
.customer-sider.ant-layout-sider-collapsed, .maintenance-sider.ant-layout-sider-collapsed, .app-sider.ant-layout-sider-collapsed { transform: translateX(-100%); }
|
||||
.ant-menu-submenu-popup { display: none !important; }
|
||||
.customer-content, .maintenance-content, .app-content { width: 100%; margin-left: 0 !important; }
|
||||
.cal-companion-panel { grid-template-columns: 1fr; }
|
||||
.cal-elder-summary { border-top: 1px solid var(--cal-hairline); border-left: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.customer-header__brand > strong, .maintenance-header__brand > strong, .maintenance-brand-divider, .maintenance-header__brand > span:last-child,
|
||||
.customer-account-button > span, .maintenance-account > span { display: none; }
|
||||
.customer-page, .maintenance-page, .app-page { padding: 28px 16px 64px !important; }
|
||||
.customer-page--home { padding-inline: 16px !important; }
|
||||
.customer-page__header h1, .maintenance-page__header h1, .app-page__header h1 { font-size: 30px !important; }
|
||||
.workspace, .ops-workspace, .customer-page > .workspace { padding: 20px !important; border-radius: 0 !important; }
|
||||
.cal-family-home__intro h1 { font-size: 32px; }
|
||||
.cal-person-switcher { display: grid; grid-template-columns: 1fr; }
|
||||
.cal-person-switcher button { width: 100%; }
|
||||
.cal-day-schedule { padding-inline: 18px; }
|
||||
.cal-day-schedule li { grid-template-columns: 48px 12px 40px minmax(0,1fr); }
|
||||
.cal-day-schedule li::before { left: 53px; }
|
||||
.cal-day-schedule li > small { display: none; }
|
||||
.cal-recent { padding: 16px 12px 6px; }
|
||||
.cal-recent > div > button { grid-template-columns: 24px 92px minmax(0,1fr) 10px; }
|
||||
.cal-recent time { display: none; }
|
||||
.cal-recent > header button { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ClockCircleOutlined, DollarOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
APIRequestError,
|
||||
platformApi,
|
||||
type Household,
|
||||
type ManagedDevice,
|
||||
type OpsDevice,
|
||||
type UsageOverview
|
||||
} from "../api/client";
|
||||
|
||||
type MetricMode = "duration" | "cost";
|
||||
type DensityMode = "daily" | "weekly" | "cumulative";
|
||||
|
||||
export function FamilyUsageOverview({ accessToken, household, devices }: {
|
||||
accessToken: string;
|
||||
household: Household;
|
||||
devices: ManagedDevice[];
|
||||
}) {
|
||||
const [deviceID, setDeviceID] = useState("");
|
||||
const [periodDays, setPeriodDays] = useState(365);
|
||||
const request = useUsageOverviewRequest(accessToken, periodDays, (range, signal) =>
|
||||
platformApi.familyUsageOverview(household.id, { ...range, device_id: deviceID || undefined }, accessToken, signal)
|
||||
, [household.id, deviceID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceID && !devices.some((item) => item.id === deviceID)) setDeviceID("");
|
||||
}, [deviceID, devices]);
|
||||
|
||||
return <UsageOverviewView
|
||||
audience="family" overview={request.overview} loading={request.loading} error={request.error}
|
||||
periodDays={periodDays} onPeriodDays={setPeriodDays} onReload={request.reload}
|
||||
householdControl={<label><span>家庭范围</span><strong>{household.name}</strong></label>}
|
||||
deviceControl={<label><span>设备</span><select value={deviceID} onChange={(event) => setDeviceID(event.target.value)}><option value="">全部设备</option>{devices.map((item) => <option key={item.id} value={item.id}>{item.subject_name} · {item.model}</option>)}</select></label>}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function OpsUsageOverview({ accessToken, tenantID }: { accessToken: string; tenantID: string }) {
|
||||
const [devices, setDevices] = useState<OpsDevice[]>([]);
|
||||
const [householdID, setHouseholdID] = useState("");
|
||||
const [deviceID, setDeviceID] = useState("");
|
||||
const [periodDays, setPeriodDays] = useState(365);
|
||||
const request = useUsageOverviewRequest(accessToken, periodDays, (range, signal) =>
|
||||
platformApi.opsUsageOverview(tenantID, { ...range, household_id: householdID || undefined, device_id: deviceID || undefined }, accessToken, signal)
|
||||
, [tenantID, householdID, deviceID]);
|
||||
|
||||
useEffect(() => {
|
||||
setHouseholdID(""); setDeviceID(""); setDevices([]);
|
||||
if (!tenantID) return;
|
||||
const controller = new AbortController();
|
||||
void platformApi.opsDevices(tenantID, accessToken, controller.signal)
|
||||
.then((response) => setDevices(response.data.items))
|
||||
.catch(() => { if (!controller.signal.aborted) setDevices([]); });
|
||||
return () => controller.abort();
|
||||
}, [accessToken, tenantID]);
|
||||
|
||||
const households = useMemo(() => {
|
||||
const unique = new Map<string, string>();
|
||||
devices.forEach((item) => { if (item.household_id && item.household_name) unique.set(item.household_id, item.household_name); });
|
||||
return [...unique.entries()].map(([id, name]) => ({ id, name })).sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
|
||||
}, [devices]);
|
||||
const availableDevices = devices.filter((item) => !householdID || item.household_id === householdID);
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceID && !availableDevices.some((item) => item.id === deviceID)) setDeviceID("");
|
||||
}, [availableDevices, deviceID]);
|
||||
|
||||
return <UsageOverviewView
|
||||
audience="operations" overview={request.overview} loading={request.loading} error={request.error}
|
||||
periodDays={periodDays} onPeriodDays={setPeriodDays} onReload={request.reload}
|
||||
householdControl={<label><span>家庭</span><select value={householdID} onChange={(event) => { setHouseholdID(event.target.value); setDeviceID(""); }}><option value="">全部家庭</option>{households.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>}
|
||||
deviceControl={<label><span>设备</span><select value={deviceID} onChange={(event) => setDeviceID(event.target.value)}><option value="">全部设备</option>{availableDevices.map((item) => <option key={item.id} value={item.id}>{item.household_name ? `${item.household_name} · ` : ""}{item.subject_name ? `${item.subject_name} · ` : ""}{item.model}</option>)}</select></label>}
|
||||
/>;
|
||||
}
|
||||
|
||||
function UsageOverviewView({ audience, overview, loading, error, periodDays, onPeriodDays, onReload, householdControl, deviceControl }: {
|
||||
audience: "family" | "operations";
|
||||
overview?: UsageOverview;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
periodDays: number;
|
||||
onPeriodDays: (value: number) => void;
|
||||
onReload: () => void;
|
||||
householdControl: React.ReactNode;
|
||||
deviceControl: React.ReactNode;
|
||||
}) {
|
||||
const [metricMode, setMetricMode] = useState<MetricMode>("duration");
|
||||
const [densityMode, setDensityMode] = useState<DensityMode>("daily");
|
||||
const [selectedDate, setSelectedDate] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!overview?.cost_available && metricMode === "cost") setMetricMode("duration");
|
||||
}, [metricMode, overview?.cost_available]);
|
||||
|
||||
const heatmap = useMemo(() => buildHeatmap(overview, metricMode, densityMode), [densityMode, metricMode, overview]);
|
||||
const selectedDay = overview?.daily.find((item) => item.date === selectedDate);
|
||||
const todayCost = overview?.daily.find((item) => item.date === localDateKey(new Date()))?.cost_micros ?? 0;
|
||||
const metrics = metricMode === "duration" ? [
|
||||
{ value: formatDuration(overview?.total_duration_ms ?? 0), label: "累计聊天时长" },
|
||||
{ value: formatDuration(overview?.today_duration_ms ?? 0), label: "今日聊天时长" },
|
||||
{ value: formatDuration(overview?.average_duration_ms ?? 0), label: "平均每次时长" },
|
||||
{ value: String(overview?.active_days ?? 0), label: "活跃天数" },
|
||||
{ value: `${overview?.current_streak_days ?? 0} 天`, label: "当前连续天数" }
|
||||
] : [
|
||||
{ value: formatMoney(overview?.cost_micros ?? 0, overview?.currency), label: "累计费用" },
|
||||
{ value: formatMoney(todayCost, overview?.currency), label: "今日费用" },
|
||||
{ value: formatMoney(overview?.conversation_count ? Math.round((overview?.cost_micros ?? 0) / overview.conversation_count) : 0, overview?.currency), label: "平均每次费用" },
|
||||
{ value: String(overview?.conversation_count ?? 0), label: "计入通话数" },
|
||||
{ value: String(overview?.active_days ?? 0), label: "产生用量天数" }
|
||||
];
|
||||
|
||||
return <section className="usage-overview" aria-label={audience === "family" ? "家庭通话时长与费用" : "运营通话时长与费用"}>
|
||||
<header className="usage-overview__toolbar">
|
||||
<div><p className="eyebrow">CONVERSATION ACTIVITY</p><h3>{metricMode === "duration" ? "通话时长概览" : "费用概览"}</h3><p>{audience === "family" ? "仅统计当前登录成员有权查看的家庭与设备。" : "查看客户组织整体数据,并按家庭或设备逐级筛选。"}</p></div>
|
||||
<div className="usage-overview__filters">{householdControl}{deviceControl}<label><span>周期</span><select value={periodDays} onChange={(event) => onPeriodDays(Number(event.target.value))}><option value={30}>近30天</option><option value={90}>近90天</option><option value={365}>近一年</option></select></label><button type="button" className="usage-overview__reload" aria-label="刷新通话用量" onClick={onReload} disabled={loading}><ReloadOutlined /></button></div>
|
||||
</header>
|
||||
|
||||
{error ? <div className="workspace__inline-error" role="alert">{error}</div> : null}
|
||||
<div className="usage-overview__mode" role="tablist" aria-label="统计口径">
|
||||
<button type="button" role="tab" aria-selected={metricMode === "duration"} className={metricMode === "duration" ? "active" : ""} onClick={() => setMetricMode("duration")}><ClockCircleOutlined />通话时长</button>
|
||||
<button type="button" role="tab" aria-selected={metricMode === "cost"} disabled={!overview?.cost_available} title={overview?.cost_available ? "查看已核价费用" : "当前范围尚无已核价费用"} className={metricMode === "cost" ? "active" : ""} onClick={() => setMetricMode("cost")}><DollarOutlined />费用{overview?.cost_available ? "" : "(暂无)"}</button>
|
||||
</div>
|
||||
|
||||
<div className={`usage-overview__metrics ${loading ? "is-loading" : ""}`}>{metrics.map((item) => <article key={item.label}><strong>{loading ? "—" : item.value}</strong><span>{item.label}</span></article>)}</div>
|
||||
|
||||
<section className="usage-activity-card">
|
||||
<header><div><strong>{metricMode === "duration" ? "聊天活动" : "费用活动"}</strong><span>{overview?.scope.device_name || overview?.scope.household_name || overview?.scope.tenant_name || "当前范围"}</span></div><div className="usage-density-tabs">{(["daily", "weekly", "cumulative"] as DensityMode[]).map((mode) => <button type="button" className={densityMode === mode ? "active" : ""} key={mode} onClick={() => setDensityMode(mode)}>{({ daily: "每日", weekly: "每周", cumulative: "累计" } as Record<DensityMode, string>)[mode]}</button>)}</div></header>
|
||||
<div className="usage-heatmap-scroll">
|
||||
<div className="usage-months">{heatmap.months.map((item) => <span key={item.key}>{item.label}</span>)}</div>
|
||||
<div className="usage-heatmap-wrap"><div className="usage-weekdays"><span>一</span><span>三</span><span>五</span></div><div className="usage-heatmap">{heatmap.cells.map((cell) => cell.date ? <button type="button" key={cell.date} className={`level-${cell.level} ${selectedDate === cell.date ? "selected" : ""}`} title={`${cell.date} · ${metricMode === "duration" ? formatDuration(cell.rawDuration ?? 0) : formatMoney(cell.rawCost ?? 0, overview?.currency)} · ${cell.count ?? 0}次`} aria-label={`${cell.date},${cell.count ?? 0}次通话`} onClick={() => setSelectedDate(cell.date)} /> : <span key={cell.key} />)}</div></div>
|
||||
</div>
|
||||
<footer><span>{selectedDay ? `${selectedDay.date}:${selectedDay.conversation_count} 次 · ${formatDuration(selectedDay.duration_ms)}${selectedDay.cost_micros ? ` · ${formatMoney(selectedDay.cost_micros, overview?.currency)}` : ""}` : "点击色块查看当天数据"}</span><div className="usage-legend"><span>少</span>{[0, 1, 2, 3, 4].map((level) => <i className={`level-${level}`} key={level} />)}<span>多</span></div></footer>
|
||||
</section>
|
||||
|
||||
<div className="usage-overview__details">
|
||||
<section><header><strong>设备使用情况</strong><span>{overview?.devices.length ?? 0} 台</span></header>{overview?.devices.length ? <div className="usage-device-list">{overview.devices.map((item) => <article key={item.id}><div><strong>{item.name}</strong><span>{audience === "operations" ? `${item.household_name} · ` : ""}{item.conversation_count} 次通话</span></div><div><strong>{formatDuration(item.duration_ms)}</strong>{overview.cost_available ? <span>{formatMoney(item.cost_micros, overview.currency)}</span> : null}</div></article>)}</div> : <p className="usage-empty">当前范围暂无设备通话数据。</p>}</section>
|
||||
<section><header><strong>最近通话</strong><span>最多20条</span></header>{overview?.recent_conversations.length ? <div className="usage-recent-list">{overview.recent_conversations.map((item) => <article key={item.id}><time>{new Date(item.started_at).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</time><div><strong>{item.subject_name} · {item.device_name}</strong><span>{audience === "operations" ? `${item.household_name} · ` : ""}{statusLabel(item.status)}</span></div><div><strong>{formatDuration(item.duration_ms)}</strong>{overview.cost_available ? <span>{formatMoney(item.cost_micros, overview.currency)}</span> : null}</div></article>)}</div> : <p className="usage-empty">当前范围暂无通话记录。</p>}</section>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function useUsageOverviewRequest(accessToken: string, periodDays: number, load: (range: { from: string; to: string; timezone: string }, signal: AbortSignal) => Promise<{ data: UsageOverview }>, dependencies: string[]) {
|
||||
const [overview, setOverview] = useState<UsageOverview>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const [revision, setRevision] = useState(0);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const to = new Date();
|
||||
const from = new Date(to);
|
||||
from.setDate(from.getDate() - periodDays + 1);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
setLoading(true); setError(undefined);
|
||||
void load({ from: from.toISOString(), to: to.toISOString(), timezone: "Asia/Shanghai" }, controller.signal)
|
||||
.then((response) => setOverview(response.data))
|
||||
.catch((cause) => { if (!controller.signal.aborted) setError(apiErrorText(cause)); })
|
||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
// The caller supplies primitive scope dependencies to keep requests aligned with visible filters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accessToken, periodDays, revision, ...dependencies]);
|
||||
return { overview, loading, error, reload: () => setRevision((value) => value + 1) };
|
||||
}
|
||||
|
||||
function buildHeatmap(overview: UsageOverview | undefined, metric: MetricMode, density: DensityMode) {
|
||||
if (!overview) return { cells: [] as HeatCell[], months: [] as Array<{ key: string; label: string }> };
|
||||
const daily = new Map(overview.daily.map((item) => [item.date, item]));
|
||||
const start = dateOnly(new Date(overview.from));
|
||||
const end = dateOnly(new Date(overview.to));
|
||||
const values: Array<{ date: string; rawDuration: number; rawCost: number; count: number; value: number }> = [];
|
||||
let cumulative = 0;
|
||||
for (let cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
|
||||
const date = localDateKey(cursor);
|
||||
const item = daily.get(date);
|
||||
const rawDuration = item?.duration_ms ?? 0;
|
||||
const rawCost = item?.cost_micros ?? 0;
|
||||
const raw = metric === "duration" ? rawDuration : rawCost;
|
||||
cumulative += raw;
|
||||
let value = raw;
|
||||
if (density === "weekly") {
|
||||
value = 0;
|
||||
for (let offset = 0; offset < 7; offset++) {
|
||||
const candidate = new Date(cursor); candidate.setDate(candidate.getDate() - offset);
|
||||
const candidateItem = daily.get(localDateKey(candidate));
|
||||
value += metric === "duration" ? candidateItem?.duration_ms ?? 0 : candidateItem?.cost_micros ?? 0;
|
||||
}
|
||||
} else if (density === "cumulative") value = cumulative;
|
||||
values.push({ date, rawDuration, rawCost, count: item?.conversation_count ?? 0, value });
|
||||
}
|
||||
const positives = values.map((item) => item.value).filter((value) => value > 0).sort((left, right) => left - right);
|
||||
const max = positives.at(-1) ?? 0;
|
||||
const level = (value: number) => value <= 0 || max <= 0 ? 0 : Math.min(4, Math.max(1, Math.ceil(value / max * 4)));
|
||||
const padding = (start.getDay() + 6) % 7;
|
||||
const cells: HeatCell[] = Array.from({ length: padding }, (_, index) => ({ key: `pad-${index}` }));
|
||||
values.forEach((item) => cells.push({ ...item, key: item.date, level: level(item.value) }));
|
||||
const months: Array<{ key: string; label: string }> = [];
|
||||
values.forEach((item) => {
|
||||
const key = item.date.slice(0, 7);
|
||||
if (!months.some((month) => month.key === key)) months.push({ key, label: `${Number(key.slice(5))}月` });
|
||||
});
|
||||
return { cells, months };
|
||||
}
|
||||
|
||||
type HeatCell = { key: string; date?: string; rawDuration?: number; rawCost?: number; count?: number; value?: number; level?: number };
|
||||
|
||||
function dateOnly(value: Date) { return new Date(value.getFullYear(), value.getMonth(), value.getDate()); }
|
||||
function localDateKey(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`; }
|
||||
function formatDuration(milliseconds: number) {
|
||||
const totalMinutes = Math.max(0, Math.round(milliseconds / 60_000));
|
||||
if (totalMinutes < 60) return `${totalMinutes}分钟`;
|
||||
const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60;
|
||||
return minutes ? `${hours}小时${minutes}分` : `${hours}小时`;
|
||||
}
|
||||
function formatMoney(micros: number, currency = "") { return `${currency || "CNY"} ${(micros / 1_000_000).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`; }
|
||||
function statusLabel(status: string) { return ({ COMPLETED: "已完成", ACTIVE: "进行中", STOPPING: "停止中", FAILED: "失败", EXPIRED: "已过期", PREPARING: "准备中", ROOM_ISSUED: "房间已分配", STARTING_AGENT: "智能体启动中" } as Record<string, string>)[status] ?? status; }
|
||||
function apiErrorText(cause: unknown) { return cause instanceof APIRequestError ? (cause.code ? `${cause.message}(${cause.code})` : cause.message) : cause instanceof Error ? cause.message : "通话用量读取失败"; }
|
||||
+40
-19
@@ -3,7 +3,11 @@ import { createRoot } from "react-dom/client";
|
||||
import { ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import { CustomerPortal } from "./pages/CustomerPortal";
|
||||
import { MaintenancePortal } from "./pages/MaintenancePortal";
|
||||
import "./styles.css";
|
||||
import "./cal-theme.css";
|
||||
|
||||
const Portal = window.location.pathname.startsWith("/maintenance") ? MaintenancePortal : CustomerPortal;
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
@@ -11,42 +15,59 @@ createRoot(document.getElementById("root")!).render(
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: "#5b5ce2",
|
||||
colorInfo: "#5b5ce2",
|
||||
colorSuccess: "#1f9d68",
|
||||
colorWarning: "#d98b12",
|
||||
colorError: "#d84a4a",
|
||||
colorText: "#1f2329",
|
||||
colorTextSecondary: "#646a73",
|
||||
colorBgLayout: "#f5f6f8",
|
||||
colorBorder: "#e3e6eb",
|
||||
colorPrimary: "#111111",
|
||||
colorInfo: "#6b7280",
|
||||
colorSuccess: "#10b981",
|
||||
colorWarning: "#f59e0b",
|
||||
colorError: "#ef4444",
|
||||
colorText: "#111111",
|
||||
colorTextSecondary: "#6b7280",
|
||||
colorBgLayout: "#ffffff",
|
||||
colorBgContainer: "#ffffff",
|
||||
colorBorder: "#e5e7eb",
|
||||
colorBorderSecondary: "#f3f4f6",
|
||||
borderRadius: 8,
|
||||
borderRadiusLG: 10,
|
||||
fontFamily: 'Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif'
|
||||
borderRadiusLG: 12,
|
||||
controlHeight: 40,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
headerBg: "#ffffff",
|
||||
siderBg: "#f7f8fa",
|
||||
bodyBg: "#f5f6f8"
|
||||
siderBg: "#ffffff",
|
||||
bodyBg: "#ffffff"
|
||||
},
|
||||
Menu: {
|
||||
itemBg: "transparent",
|
||||
itemSelectedBg: "#ebe9ff",
|
||||
itemSelectedColor: "#5b5ce2",
|
||||
itemSelectedBg: "#ffffff",
|
||||
itemSelectedColor: "#111111",
|
||||
itemBorderRadius: 8
|
||||
},
|
||||
Button: {
|
||||
borderRadius: 8,
|
||||
controlHeight: 36
|
||||
borderRadius: 9999,
|
||||
controlHeight: 40,
|
||||
primaryShadow: "none"
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 10
|
||||
borderRadiusLG: 12
|
||||
},
|
||||
Input: {
|
||||
borderRadius: 8,
|
||||
activeBorderColor: "#111111",
|
||||
hoverBorderColor: "#111111",
|
||||
activeShadow: "0 0 0 2px rgba(17, 17, 17, 0.08)"
|
||||
},
|
||||
Select: {
|
||||
borderRadius: 8,
|
||||
activeBorderColor: "#111111",
|
||||
hoverBorderColor: "#111111",
|
||||
activeOutlineColor: "rgba(17, 17, 17, 0.08)"
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CustomerPortal />
|
||||
<Portal />
|
||||
</ConfigProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
type MenuProps
|
||||
} from "antd";
|
||||
import {
|
||||
AudioOutlined,
|
||||
BellOutlined,
|
||||
BookOutlined,
|
||||
CaretDownOutlined,
|
||||
ClockCircleOutlined,
|
||||
CommentOutlined,
|
||||
CreditCardOutlined,
|
||||
DashboardOutlined,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MessageOutlined,
|
||||
PictureOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
@@ -55,17 +58,18 @@ const sectionMeta: Record<PortalSection, { title: string; description: string; b
|
||||
stories: { title: "故事与记忆", description: "核对智能整理的故事,沉淀经过家庭确认的长期记忆。", breadcrumb: "故事与记忆" },
|
||||
sharing: { title: "资源分享", description: "面向指定家人分享故事、文字或录音,并随时撤销权限。", breadcrumb: "授权与隐私 / 资源分享" },
|
||||
privacy: { title: "隐私请求", description: "申请访问、导出或删除个人数据,并跟踪处理状态。", breadcrumb: "授权与隐私 / 隐私请求" },
|
||||
billing: { title: "费用与额度", description: "查看当前家庭可见通话的实际计量和费用说明。", breadcrumb: "费用与额度" },
|
||||
billing: { title: "用量与费用", description: "查看本家庭及设备的通话时长;存在已核价记录时可切换费用。", breadcrumb: "用量与费用" },
|
||||
messages: { title: "消息中心", description: "查看设备、通话、故事和隐私请求的实时状态提醒。", breadcrumb: "消息中心" },
|
||||
profile: { title: "个人中心", description: "查看当前登录身份、客户组织和家庭角色。", breadcrumb: "个人中心" }
|
||||
};
|
||||
|
||||
export function CustomerPortal() {
|
||||
const auth = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720);
|
||||
const [section, setSection] = useState<PortalSection>(() => sectionFromURL());
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const meta = sectionMeta[section];
|
||||
const designPreview = new URL(window.location.href).searchParams.get("designPreview") === "1" && section === "home";
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setSection(sectionFromURL());
|
||||
@@ -89,7 +93,7 @@ export function CustomerPortal() {
|
||||
{ key: "devices", icon: <LaptopOutlined />, label: "设备管理" },
|
||||
{ key: "conversations", icon: <CommentOutlined />, label: "通话记录" },
|
||||
{ key: "stories", icon: <BookOutlined />, label: "故事与记忆" },
|
||||
{ key: "billing", icon: <CreditCardOutlined />, label: "费用与额度" },
|
||||
{ key: "billing", icon: <CreditCardOutlined />, label: "用量与费用" },
|
||||
{ key: "messages", icon: <MessageOutlined />, label: "消息中心" },
|
||||
{ type: "divider" },
|
||||
{ key: "profile", icon: <UserOutlined />, label: "个人中心" }
|
||||
@@ -97,7 +101,9 @@ export function CustomerPortal() {
|
||||
|
||||
function navigate(next: PortalSection) {
|
||||
setSection(next);
|
||||
if (window.innerWidth <= 720) setCollapsed(true);
|
||||
const url = new URL(window.location.href);
|
||||
if (next !== "home") url.searchParams.delete("designPreview");
|
||||
if (next === "home") url.searchParams.delete("section"); else url.searchParams.set("section", next);
|
||||
window.history.pushState({}, "", url);
|
||||
}
|
||||
@@ -128,19 +134,20 @@ export function CustomerPortal() {
|
||||
return <Layout className="customer-layout">
|
||||
<Layout.Header className="customer-header">
|
||||
<div className="customer-header__brand">
|
||||
<Button type="text" icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} aria-label={collapsed ? "展开菜单" : "收起菜单"} onClick={() => setCollapsed((value) => !value)} />
|
||||
<Button className="customer-menu-toggle" type="text" icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} aria-label={collapsed ? "展开菜单" : "收起菜单"} onClick={() => setCollapsed((value) => !value)} />
|
||||
<span className="customer-brand-mark" aria-hidden="true"><HeartOutlined /></span>
|
||||
<strong>安心陪伴</strong>
|
||||
<span className="customer-brand-divider" />
|
||||
<span>家庭中心</span>
|
||||
</div>
|
||||
<nav className="customer-top-nav" aria-label="家庭中心主导航">
|
||||
{([['home', '首页'], ['members', '家人'], ['devices', '设备'], ['conversations', '对话'], ['stories', '回忆'], ['profile', '设置']] as Array<[PortalSection, string]>).map(([key, label]) => <button type="button" className={section === key ? "active" : ""} key={key} onClick={() => navigate(key)}>{label}</button>)}
|
||||
</nav>
|
||||
<div className="customer-header__actions">
|
||||
<Input.Search value={searchValue} onChange={(event) => setSearchValue(event.target.value)} onSearch={search} prefix={<SearchOutlined />} placeholder="搜索长者、设备、通话" allowClear className="customer-search" />
|
||||
<Tooltip title="消息中心"><Badge dot><Button type="text" icon={<BellOutlined />} aria-label="打开消息中心" onClick={() => navigate("messages")} /></Badge></Tooltip>
|
||||
{auth.currentUser ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight">
|
||||
{auth.currentUser || designPreview ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight">
|
||||
<Button type="text" className="customer-account-button">
|
||||
<Avatar size={30} icon={<UserOutlined />} />
|
||||
<span>{auth.currentUser.identity.display_name || auth.currentUser.identity.preferred_username}</span>
|
||||
<Avatar size={36} src={designPreview ? "/assets/family-chen.png" : undefined} icon={<UserOutlined />} />
|
||||
<span>{designPreview ? "陈强" : auth.currentUser!.identity.display_name || auth.currentUser!.identity.preferred_username}</span>
|
||||
<CaretDownOutlined />
|
||||
</Button>
|
||||
</Dropdown> : <Button type="primary" onClick={() => void auth.login()}>登录</Button>}
|
||||
@@ -155,24 +162,65 @@ export function CustomerPortal() {
|
||||
</Layout.Sider>
|
||||
|
||||
<Layout.Content className="customer-content">
|
||||
<main className="customer-page">
|
||||
<Breadcrumb items={[{ title: "家庭中心" }, { title: meta.breadcrumb }]} />
|
||||
<header className="customer-page__header">
|
||||
<main className={`customer-page customer-page--${section}`}>
|
||||
{designPreview ? null : <Breadcrumb items={[{ title: "家庭中心" }, { title: meta.breadcrumb }]} />}
|
||||
{designPreview ? null : <header className="customer-page__header">
|
||||
<div><h1>{meta.title}</h1><p>{meta.description}</p></div>
|
||||
{auth.currentUser ? <Tag color="green">家庭权限已校验</Tag> : null}
|
||||
</header>
|
||||
</header>}
|
||||
|
||||
{auth.loading ? <Card className="customer-auth-state"><Spin /><span>正在读取登录状态…</span></Card> : null}
|
||||
{!auth.loading && auth.error ? <Alert type="error" showIcon message="登录信息读取失败" description={auth.error} action={<Space><Button onClick={() => void auth.retry()}>重试</Button><Button type="primary" onClick={() => void auth.login()}>重新登录</Button></Space>} /> : null}
|
||||
{!auth.loading && !auth.currentUser && !auth.error ? <CustomerLogin onLogin={() => void auth.login()} /> : null}
|
||||
{auth.currentUser && auth.oidcUser && section !== "profile" ? <SaaSWorkspace accessToken={auth.oidcUser.access_token} accounts={auth.currentUser.accounts} section={section} onSectionChange={navigate} showNavigation={false} /> : null}
|
||||
{auth.currentUser && section === "profile" ? <CustomerProfile currentUser={auth.currentUser} onLogout={() => void auth.logout()} /> : null}
|
||||
{designPreview ? <FamilyHomePreview onNavigate={navigate} /> : null}
|
||||
{!designPreview && auth.loading ? <Card className="customer-auth-state"><Spin /><span>正在读取登录状态…</span></Card> : null}
|
||||
{!designPreview && !auth.loading && auth.error ? <Alert type="error" showIcon message="登录信息读取失败" description={auth.error} action={<Space><Button onClick={() => void auth.retry()}>重试</Button><Button type="primary" onClick={() => void auth.login()}>重新登录</Button></Space>} /> : null}
|
||||
{!designPreview && !auth.loading && !auth.currentUser && !auth.error ? <CustomerLogin onLogin={() => void auth.login()} /> : null}
|
||||
{!designPreview && auth.currentUser && auth.oidcUser && section !== "profile" ? <SaaSWorkspace accessToken={auth.oidcUser.access_token} accounts={auth.currentUser.accounts} section={section} onSectionChange={navigate} showNavigation={false} /> : null}
|
||||
{!designPreview && auth.currentUser && section === "profile" ? <CustomerProfile currentUser={auth.currentUser} onLogout={() => void auth.logout()} /> : null}
|
||||
</main>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>;
|
||||
}
|
||||
|
||||
function FamilyHomePreview({ onNavigate }: { onNavigate: (section: PortalSection) => void }) {
|
||||
const [activePerson, setActivePerson] = useState("lin");
|
||||
const people = [
|
||||
{ id: "lin", name: "林秀兰", relation: "妈妈", image: "/assets/elder-lin.png" },
|
||||
{ id: "chen", name: "陈建国", relation: "爸爸", image: "/assets/family-chen.png" },
|
||||
{ id: "wang", name: "王素梅", relation: "婶婶", image: "/assets/family-wang.png" }
|
||||
];
|
||||
const active = people.find((person) => person.id === activePerson)!;
|
||||
return <section className="cal-family-home" aria-label="家庭陪伴总览设计预览">
|
||||
<header className="cal-family-home__intro"><h1>家庭陪伴总览</h1><p>下午好,陈强!一起看看家人今天的陪伴情况吧。</p></header>
|
||||
<div className="cal-person-switcher" role="tablist" aria-label="切换家人">
|
||||
{people.map((person) => <button type="button" role="tab" aria-selected={activePerson === person.id} className={activePerson === person.id ? "active" : ""} key={person.id} onClick={() => setActivePerson(person.id)}><img src={person.image} alt="" /><strong>{person.name}</strong><span>· {person.relation}</span>{activePerson === person.id ? <CaretDownOutlined /> : null}</button>)}
|
||||
</div>
|
||||
<section className="cal-companion-panel">
|
||||
<div className="cal-day-schedule">
|
||||
<h2>今天</h2>
|
||||
<ol>
|
||||
<li className="done"><time>08:30</time><span className="cal-timeline-dot" /><span className="cal-event-icon"><CommentOutlined /></span><div><strong>语音通话</strong><p>与{active.relation}通话 18 分钟</p></div><small>已完成</small></li>
|
||||
<li className="done"><time>10:15</time><span className="cal-timeline-dot" /><span className="cal-event-icon"><HeartOutlined /></span><div><strong>安心陪伴</strong><p>AI 陪伴聊天 22 分钟</p></div><small>已完成</small></li>
|
||||
<li className="done"><time>14:30</time><span className="cal-timeline-dot" /><span className="cal-event-icon"><PictureOutlined /></span><div><strong>回忆确认</strong><p>「1985年夏天的海边」已确认</p></div><small>已完成</small></li>
|
||||
<li><time>16:00</time><span className="cal-timeline-dot" /><span className="cal-event-icon cal-event-icon--warm"><BellOutlined /></span><div><strong>即将陪伴</strong><p>与{active.relation}的每日陪伴提醒</p></div><small>16:00 准时开始 ›</small></li>
|
||||
</ol>
|
||||
<footer><ClockCircleOutlined /> 每天 16:00,我们会提醒您与{active.relation}进行语音或聊天陪伴</footer>
|
||||
</div>
|
||||
<aside className="cal-elder-summary">
|
||||
<header><img src={active.image} alt={`${active.name}头像`} /><div><h2>{active.name} <span>{active.relation}</span></h2><p>78岁 · 北京市</p></div></header>
|
||||
<p className="cal-online"><span />在线</p><p className="cal-muted">设备在线,语音畅通</p>
|
||||
<div className="cal-device-row"><img src="/assets/companion-speaker.png" alt="安心陪伴音箱 Pro" /><div><strong>安心陪伴音箱 Pro</strong><p>客厅</p></div><small>电量 82%</small></div>
|
||||
<div className="cal-latest-call"><strong>最近对话</strong><button type="button" onClick={() => onNavigate("conversations")}><span className="cal-event-icon"><AudioOutlined /></span><span>“今天天气不错,楼下的花开得可漂亮了,我去散了个步。”<small>今天 10:15 · 22 分钟</small></span><b>›</b></button></div>
|
||||
<button type="button" className="cal-primary-action" onClick={() => onNavigate("conversations")}>安排一次陪伴</button>
|
||||
</aside>
|
||||
</section>
|
||||
<section className="cal-recent"><header><h2>最近发生</h2><button type="button" onClick={() => onNavigate("messages")}>查看全部记录 ›</button></header><div>
|
||||
<button type="button" onClick={() => onNavigate("conversations")}><AudioOutlined /><strong>新的对话</strong><span>{active.relation}与 AI 助手聊了「春天的记忆」</span><time>今天 10:15</time><b>›</b></button>
|
||||
<button type="button" onClick={() => onNavigate("stories")}><PictureOutlined /><strong>回忆已确认</strong><span>「1985年夏天的海边」已由{active.relation}确认并保存</span><time>今天 14:30</time><b>›</b></button>
|
||||
<button type="button" onClick={() => onNavigate("members")}><UserOutlined /><strong>家人邀请</strong><span>王素梅邀请您加入家庭,已通过</span><time>昨天 20:45</time><b>›</b></button>
|
||||
</div></section>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function CustomerLogin({ onLogin }: { onLogin: () => void }) {
|
||||
return <Card className="customer-login-card">
|
||||
<span className="customer-login-icon"><LockOutlined /></span>
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Input,
|
||||
Layout,
|
||||
Menu,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
type MenuProps
|
||||
} from "antd";
|
||||
import {
|
||||
AlertOutlined,
|
||||
ApiOutlined,
|
||||
BarChartOutlined,
|
||||
BellOutlined,
|
||||
CloudServerOutlined,
|
||||
DashboardOutlined,
|
||||
DollarOutlined,
|
||||
DatabaseOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { platformApi, type ContentQualitySummary } from "../api/client";
|
||||
import { StatusCard } from "../components/StatusCard";
|
||||
import { OpsAlertWorkspace } from "./OpsAlertWorkspace";
|
||||
import { OpsBillingWorkspace } from "./OpsBillingWorkspace";
|
||||
import { OpsConfigWorkspace } from "./OpsConfigWorkspace";
|
||||
import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace";
|
||||
import { OpsDeviceWorkspace } from "./OpsDeviceWorkspace";
|
||||
|
||||
type MaintenanceArea = "overview" | "devices" | "config" | "quality" | "billing" | "alerts" | "diagnostics";
|
||||
|
||||
type HealthState = {
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
live?: string;
|
||||
service?: string;
|
||||
ready?: string;
|
||||
checks?: Record<string, string>;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
const areaMeta: Record<MaintenanceArea, { title: string; description: string; icon: ReactNode }> = {
|
||||
overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> },
|
||||
devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> },
|
||||
config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: <SettingOutlined /> },
|
||||
quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: <BarChartOutlined /> },
|
||||
billing: { title: "用量、费用与额度", description: "查看整体通话时长并按家庭、设备筛选,同时维护核价与硬额度策略。", icon: <DollarOutlined /> },
|
||||
alerts: { title: "告警处置", description: "维护告警路由、通知测试、静默窗口和处置时间线。", icon: <AlertOutlined /> },
|
||||
diagnostics: { title: "联调与诊断", description: "处理依赖异常、失败作业、敏感访问、验收快照和恢复操作。", icon: <ToolOutlined /> }
|
||||
};
|
||||
|
||||
export function MaintenancePortal() {
|
||||
const auth = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720);
|
||||
const [area, setArea] = useState<MaintenanceArea>(() => areaFromURL());
|
||||
const [health, setHealth] = useState<HealthState>({ loading: true });
|
||||
|
||||
const refreshHealth = useCallback(async (signal?: AbortSignal) => {
|
||||
setHealth((current) => ({ ...current, loading: true, error: undefined }));
|
||||
try {
|
||||
const [live, ready, version] = await Promise.all([
|
||||
platformApi.liveness(signal),
|
||||
platformApi.readiness(signal),
|
||||
platformApi.version(signal)
|
||||
]);
|
||||
setHealth({
|
||||
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;
|
||||
setHealth({ loading: false, error: error instanceof Error ? error.message : "未知错误" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void refreshHealth(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [refreshHealth]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setArea(areaFromURL());
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, []);
|
||||
|
||||
const operationsAccounts = useMemo(
|
||||
() => auth.currentUser?.accounts.filter((account) => account.account_kind === "operator" && account.platform_role !== "customer") ?? [],
|
||||
[auth.currentUser]
|
||||
);
|
||||
const hasOperationsAccess = operationsAccounts.length > 0;
|
||||
const meta = areaMeta[area];
|
||||
|
||||
function navigate(next: MaintenanceArea) {
|
||||
setArea(next);
|
||||
if (window.innerWidth <= 720) setCollapsed(true);
|
||||
const url = new URL(window.location.href);
|
||||
if (next === "overview") url.searchParams.delete("area"); else url.searchParams.set("area", next);
|
||||
window.history.pushState({ area: next }, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
const menuItems: MenuProps["items"] = [
|
||||
{ type: "group", label: "平台维护", children: [
|
||||
{ key: "overview", icon: <DashboardOutlined />, label: "维护总览" },
|
||||
{ key: "devices", icon: <DatabaseOutlined />, label: "设备管理" },
|
||||
{ key: "config", icon: <SettingOutlined />, label: "运行配置" },
|
||||
{ key: "quality", icon: <BarChartOutlined />, label: "内容质量" },
|
||||
{ key: "billing", icon: <DollarOutlined />, label: "用量与费用" },
|
||||
{ key: "alerts", icon: <AlertOutlined />, label: "告警处置" },
|
||||
{ key: "diagnostics", icon: <ToolOutlined />, label: "联调与诊断" }
|
||||
] }
|
||||
];
|
||||
|
||||
const accountMenu: MenuProps["items"] = [
|
||||
{ key: "logout", icon: <UserOutlined />, label: "退出登录", onClick: () => void auth.logout() }
|
||||
];
|
||||
|
||||
return <Layout className="maintenance-layout">
|
||||
<Layout.Header className="maintenance-header">
|
||||
<div className="maintenance-header__brand">
|
||||
<Button className="maintenance-menu-toggle" type="text" icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} aria-label={collapsed ? "展开菜单" : "收起菜单"} onClick={() => setCollapsed((value) => !value)} />
|
||||
<span className="maintenance-brand-mark"><CloudServerOutlined /></span>
|
||||
<strong>安心陪伴</strong><span className="maintenance-brand-divider" /><span>维护中心</span>
|
||||
</div>
|
||||
<nav className="maintenance-top-nav" aria-label="维护中心主导航">
|
||||
{(Object.entries(areaMeta) as Array<[MaintenanceArea, (typeof areaMeta)[MaintenanceArea]]>).map(([key, item]) => <button type="button" className={area === key ? "active" : ""} key={key} onClick={() => navigate(key)}>{item.title.replace("维护", "")}</button>)}
|
||||
</nav>
|
||||
<div className="maintenance-header__actions">
|
||||
<Input prefix={<SearchOutlined />} placeholder="搜索设备、通话、作业" allowClear className="maintenance-search" />
|
||||
<Tooltip title="平台就绪状态"><Badge status={health.ready === "ready" ? "success" : health.loading ? "processing" : "warning"} /></Tooltip>
|
||||
<Button type="text" icon={<BellOutlined />} aria-label="查看告警" onClick={() => navigate("alerts")} />
|
||||
{auth.currentUser ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight"><Button type="text" className="maintenance-account"><Avatar size={30} icon={<UserOutlined />} /><span>{auth.currentUser.identity.display_name || auth.currentUser.identity.preferred_username}</span></Button></Dropdown> : <Button type="primary" onClick={() => void auth.login()}>维护人员登录</Button>}
|
||||
</div>
|
||||
</Layout.Header>
|
||||
|
||||
<Layout className="maintenance-layout__body">
|
||||
<Layout.Sider className="maintenance-sider" width={224} collapsedWidth={72} collapsed={collapsed} trigger={null}>
|
||||
<div className="maintenance-sider__title"><ApiOutlined />{collapsed ? null : <span>平台运维控制台</span>}</div>
|
||||
<Menu mode="inline" selectedKeys={[area]} items={menuItems} onClick={({ key }) => navigate(key as MaintenanceArea)} />
|
||||
<div className="maintenance-sider__meta">{collapsed ? <CloudServerOutlined /> : <><span>当前构建</span><strong>{health.version ?? "dev"}</strong></>}</div>
|
||||
</Layout.Sider>
|
||||
|
||||
<Layout.Content className="maintenance-content">
|
||||
<main className="maintenance-page">
|
||||
<Breadcrumb items={[{ title: "维护中心" }, { title: meta.title }]} />
|
||||
<header className="maintenance-page__header"><div><span className="maintenance-page__icon">{meta.icon}</span><div><h1>{meta.title}</h1><p>{meta.description}</p></div></div><Button icon={<ReloadOutlined />} loading={health.loading} onClick={() => void refreshHealth()}>刷新状态</Button></header>
|
||||
|
||||
{auth.loading ? <Card className="maintenance-auth-state"><Spin /><span>正在校验维护身份…</span></Card> : null}
|
||||
{!auth.loading && auth.error ? <Alert type="error" showIcon message="维护身份读取失败" description={auth.error} action={<Space><Button onClick={() => void auth.retry()}>重试</Button><Button type="primary" onClick={() => void auth.login()}>重新登录</Button></Space>} /> : null}
|
||||
{!auth.loading && !auth.currentUser && !auth.error ? <MaintenanceLogin onLogin={() => void auth.login()} /> : null}
|
||||
{auth.currentUser && !hasOperationsAccess ? <Alert type="warning" showIcon message="当前账号没有维护权限" description="家庭客户角色不能进入维护端。请使用 ops_viewer、ops_operator、ops_approver 或 ops_admin 账号登录。" action={<Button onClick={() => window.location.assign("/")}>返回家庭端</Button>} /> : null}
|
||||
{health.error ? <Alert type="error" showIcon message="平台健康检查失败" description={health.error} /> : null}
|
||||
|
||||
{auth.currentUser && auth.oidcUser && hasOperationsAccess ? <section className="maintenance-workspace">
|
||||
{area === "overview" ? <MaintenanceOverview health={health} accounts={operationsAccounts} /> : null}
|
||||
{area === "devices" ? <OpsDeviceWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
{area === "config" ? <OpsConfigWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
{area === "quality" ? <ContentQualityWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
{area === "billing" ? <OpsBillingWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
{area === "alerts" ? <OpsAlertWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
{area === "diagnostics" ? <OpsDiagnosticsWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
</section> : null}
|
||||
</main>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>;
|
||||
}
|
||||
|
||||
function ContentQualityWorkspace({ accessToken, accounts }: {
|
||||
accessToken: string;
|
||||
accounts: NonNullable<ReturnType<typeof useAuth>["currentUser"]>["accounts"];
|
||||
}) {
|
||||
const [tenantID, setTenantID] = useState(accounts[0]?.tenant_id ?? "");
|
||||
const [items, setItems] = useState<ContentQualitySummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [revision, setRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accounts.some((account) => account.tenant_id === tenantID)) setTenantID(accounts[0]?.tenant_id ?? "");
|
||||
}, [accounts, tenantID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantID) { setItems([]); return; }
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
void platformApi.opsContentQuality(tenantID, accessToken, controller.signal)
|
||||
.then((response) => setItems(response.data.items))
|
||||
.catch((cause) => {
|
||||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(cause instanceof Error ? cause.message : "内容质量读取失败");
|
||||
})
|
||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [accessToken, revision, tenantID]);
|
||||
|
||||
const totals = items.reduce((result, item) => ({
|
||||
candidates: result.candidates + item.story_candidates + item.fact_candidates,
|
||||
confirmed: result.confirmed + item.confirmed,
|
||||
edited: result.edited + item.edited,
|
||||
rejected: result.rejected + item.rejected,
|
||||
issues: result.issues + Object.values(item.error_types).reduce((sum, count) => sum + count, 0)
|
||||
}), { candidates: 0, confirmed: 0, edited: 0, rejected: 0, issues: 0 });
|
||||
const reviewed = totals.confirmed + totals.edited + totals.rejected;
|
||||
|
||||
return <div className="quality-workspace">
|
||||
<Card className="quality-toolbar">
|
||||
<div><strong>质量反馈范围</strong><span>只展示当前维护账号有权访问的客户组织;统计不包含故事、事实或记忆正文。</span></div>
|
||||
<Space wrap>
|
||||
<Select value={tenantID || undefined} style={{ minWidth: 240 }} placeholder="选择客户组织" onChange={setTenantID} options={accounts.map((account) => ({ value: account.tenant_id, label: account.tenant_name }))} />
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => setRevision((value) => value + 1)}>刷新质量数据</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
{error ? <Alert type="error" showIcon message="内容质量读取失败" description={error} /> : null}
|
||||
<section className="quality-summary-grid">
|
||||
<QualityMetric label="生成候选" value={totals.candidates} detail="故事候选与事实候选合计" />
|
||||
<QualityMetric label="人工审核" value={reviewed} detail={`确认 ${totals.confirmed} · 修改 ${totals.edited} · 拒绝 ${totals.rejected}`} />
|
||||
<QualityMetric label="标记问题" value={totals.issues} detail="九类标准错误标签合计" />
|
||||
<QualityMetric label="确认率" value={reviewed ? `${Math.round(totals.confirmed / reviewed * 100)}%` : "—"} detail="确认数 ÷ 已审核操作数" />
|
||||
</section>
|
||||
<Card className="quality-detail-card" title="模型与提示词版本明细" extra={<Tag color="blue">不展示私密正文</Tag>}>
|
||||
{loading && !items.length ? <div className="quality-state"><Spin /><span>正在聚合审核信号…</span></div> : null}
|
||||
{!loading && !items.length ? <div className="quality-state"><BarChartOutlined /><strong>暂无质量反馈</strong><span>家庭端完成故事、事实或记忆审核后,这里会按生成版本汇总。</span></div> : null}
|
||||
{items.length ? <div className="quality-version-list">{items.map((item) => {
|
||||
const itemReviewed = item.confirmed + item.edited + item.rejected;
|
||||
return <article key={`${item.model_ref}\u0000${item.prompt_version}`}>
|
||||
<header><div><strong>{item.model_ref}</strong><span>提示词 {item.prompt_version}</span></div><Tag>{itemReviewed} 次审核</Tag></header>
|
||||
<div className="quality-version-metrics"><span>故事候选 <strong>{item.story_candidates}</strong></span><span>事实候选 <strong>{item.fact_candidates}</strong></span><span>确认 <strong>{item.confirmed}</strong></span><span>修改 <strong>{item.edited}</strong></span><span>拒绝 <strong>{item.rejected}</strong></span><span>停用 <strong>{item.disabled}</strong></span></div>
|
||||
<div className="quality-error-types"><small>问题类型</small>{Object.entries(item.error_types).length ? Object.entries(item.error_types).sort((left, right) => right[1] - left[1]).map(([type, count]) => <Tag color="volcano" key={type}>{contentErrorTypeLabel(type)} {count}</Tag>) : <span>尚未标记问题类型</span>}</div>
|
||||
</article>;
|
||||
})}</div> : null}
|
||||
</Card>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function QualityMetric({ label, value, detail }: { label: string; value: number | string; detail: string }) {
|
||||
return <Card className="quality-metric"><span>{label}</span><strong>{value}</strong><small>{detail}</small></Card>;
|
||||
}
|
||||
|
||||
function contentErrorTypeLabel(value: string) {
|
||||
return ({ PERSON_ERROR: "人物错误", TIME_ERROR: "时间错误", PLACE_ERROR: "地点错误", RELATION_ERROR: "关系错误", OVER_INFERENCE: "过度推断", KEY_POINT_OMISSION: "遗漏重点", DUPLICATE_CONTENT: "重复内容", UNNATURAL_EXPRESSION: "表达不自然", SENSITIVE_INFORMATION: "敏感信息" } as Record<string, string>)[value] ?? value;
|
||||
}
|
||||
|
||||
function MaintenanceLogin({ onLogin }: { onLogin: () => void }) {
|
||||
return <Card className="maintenance-login"><ToolOutlined /><h2>维护人员登录</h2><p>维护端与家庭端分离,仅向平台运营、审批和管理员角色开放;所有敏感查看和控制操作继续由后端审计。</p><Button type="primary" size="large" onClick={onLogin}>登录维护中心</Button></Card>;
|
||||
}
|
||||
|
||||
function MaintenanceOverview({ health, accounts }: { health: HealthState; accounts: NonNullable<ReturnType<typeof useAuth>["currentUser"]>["accounts"] }) {
|
||||
const dependencySummary = health.checks ? Object.entries(health.checks).map(([name, status]) => `${name}: ${status}`).join(" · ") : "等待依赖检查";
|
||||
return <>
|
||||
<section className="maintenance-status-grid">
|
||||
<StatusCard label="API 进程" value={health.loading ? "检查中" : health.live === "alive" ? "运行正常" : "需要处理"} detail={health.service ? `当前服务:${health.service}` : "尚未获得服务信息"} tone={health.live === "alive" ? "good" : "warning"} />
|
||||
<StatusCard label="必要依赖" value={health.loading ? "检查中" : health.ready === "ready" ? "全部就绪" : "存在异常"} detail={dependencySummary} tone={health.ready === "ready" ? "good" : health.loading ? "neutral" : "warning"} />
|
||||
<StatusCard label="构建版本" value={health.version ?? "dev"} detail="版本、提交与构建时间由流水线注入" tone="neutral" />
|
||||
</section>
|
||||
<Card className="maintenance-role-card" title="当前维护范围" extra={<Tag color="green">服务端权限校验</Tag>}>
|
||||
<div className="maintenance-role-list">{accounts.map((account) => <article key={account.user_id}><strong>{account.tenant_name}</strong><span>{roleLabel(account.platform_role)}</span><small>配置、费用、告警和诊断数据均按该客户组织隔离</small></article>)}</div>
|
||||
</Card>
|
||||
</>;
|
||||
}
|
||||
|
||||
function areaFromURL(): MaintenanceArea {
|
||||
const value = new URL(window.location.href).searchParams.get("area");
|
||||
return value && value in areaMeta ? value as MaintenanceArea : "overview";
|
||||
}
|
||||
|
||||
function roleLabel(role: string) {
|
||||
return ({ ops_viewer: "维护查看员", ops_operator: "维护操作员", ops_approver: "维护审批员", ops_admin: "维护管理员" } as Record<string, string>)[role] ?? role;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
APIRequestError, platformApi, type BillingPriceBook, type CostSummary,
|
||||
type CurrentUser, type QuotaPolicy
|
||||
} from "../api/client";
|
||||
import { OpsUsageOverview } from "../components/UsageOverview";
|
||||
|
||||
const categories = ["RTC", "END_TO_END_VOICE", "ASR", "LLM", "TTS", "STORAGE", "POST_PROCESSING"] as const;
|
||||
|
||||
@@ -132,10 +133,12 @@ export function OpsBillingWorkspace({ accessToken, accounts }: { accessToken: st
|
||||
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>
|
||||
<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}
|
||||
|
||||
<OpsUsageOverview accessToken={accessToken} tenantID={tenantID} />
|
||||
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message } from "antd";
|
||||
import { DeleteOutlined, EditOutlined, KeyOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined } from "@ant-design/icons";
|
||||
import { platformApi, type DeviceClaimIssue, type DeviceCredentialIssue, type OpsDevice, type OpsDeviceAssignmentOption } from "../api/client";
|
||||
import type { CurrentUser } from "../api/client";
|
||||
|
||||
type Account = CurrentUser["accounts"][number];
|
||||
type DeviceForm = {
|
||||
model: string;
|
||||
hardware_revision?: string;
|
||||
minimum_security_version?: string;
|
||||
minimum_firmware_version?: string;
|
||||
assignment?: string;
|
||||
status?: "registered" | "quarantined";
|
||||
};
|
||||
|
||||
export function OpsDeviceWorkspace({ accessToken, accounts }: { accessToken: string; accounts: Account[] }) {
|
||||
const [tenantID, setTenantID] = useState(accounts[0]?.tenant_id ?? "");
|
||||
const [devices, setDevices] = useState<OpsDevice[]>([]);
|
||||
const [options, setOptions] = useState<OpsDeviceAssignmentOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [editing, setEditing] = useState<OpsDevice>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [form] = Form.useForm<DeviceForm>();
|
||||
const account = accounts.find((item) => item.tenant_id === tenantID);
|
||||
const canOperate = account?.account_kind === "operator" && ["ops_operator", "ops_admin"].includes(account.platform_role);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accounts.some((item) => item.tenant_id === tenantID)) setTenantID(accounts[0]?.tenant_id ?? "");
|
||||
}, [accounts, tenantID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantID) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
Promise.all([
|
||||
platformApi.opsDevices(tenantID, accessToken, controller.signal),
|
||||
platformApi.opsDeviceAssignmentOptions(tenantID, accessToken, controller.signal)
|
||||
]).then(([deviceResponse, optionResponse]) => {
|
||||
setDevices(deviceResponse.data.items);
|
||||
setOptions(optionResponse.data.items);
|
||||
}).catch((cause) => {
|
||||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(cause instanceof Error ? cause.message : "设备列表读取失败");
|
||||
}).finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [accessToken, revision, tenantID]);
|
||||
|
||||
const assignmentOptions = useMemo(() => options.map((item) => ({
|
||||
value: `${item.household_id}|${item.subject_id}`,
|
||||
label: `${item.household_name} / ${item.subject_name}`
|
||||
})), [options]);
|
||||
|
||||
function beginCreate() {
|
||||
setEditing(undefined);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ model: "voice-terminal", status: "registered" });
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function beginEdit(device: OpsDevice) {
|
||||
setEditing(device);
|
||||
form.setFieldsValue({
|
||||
model: device.model,
|
||||
hardware_revision: device.hardware_revision,
|
||||
minimum_security_version: device.minimum_security_version,
|
||||
minimum_firmware_version: device.minimum_firmware_version,
|
||||
assignment: device.household_id && device.subject_id ? `${device.household_id}|${device.subject_id}` : undefined,
|
||||
status: device.status === "quarantined" ? "quarantined" : "registered"
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const values = await form.validateFields();
|
||||
const [householdID, subjectID] = values.assignment?.split("|") ?? [];
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await platformApi.updateOpsDevice(tenantID, editing, {
|
||||
model: values.model,
|
||||
hardware_revision: values.hardware_revision ?? "",
|
||||
minimum_security_version: values.minimum_security_version ?? "",
|
||||
minimum_firmware_version: values.minimum_firmware_version ?? "",
|
||||
status: values.status,
|
||||
household_id: householdID ?? "",
|
||||
subject_id: subjectID ?? ""
|
||||
}, accessToken);
|
||||
message.success("设备信息已更新");
|
||||
} else {
|
||||
const response = await platformApi.createOpsDevice(tenantID, {
|
||||
model: values.model,
|
||||
hardware_revision: values.hardware_revision,
|
||||
minimum_security_version: values.minimum_security_version,
|
||||
minimum_firmware_version: values.minimum_firmware_version,
|
||||
household_id: householdID,
|
||||
subject_id: subjectID
|
||||
}, accessToken);
|
||||
downloadCredential(response.data.credential);
|
||||
if (response.data.claim) {
|
||||
showClaim(response.data.claim, "设备已创建,密钥文件已下载");
|
||||
} else {
|
||||
Modal.success({ title: "设备已创建,密钥文件已下载", content: `设备 ID:${response.data.device.id}。该设备已直接分配给长者,无需绑定码。密钥明文不会再次显示,请立即交付到对应设备安全区。` });
|
||||
}
|
||||
}
|
||||
setOpen(false);
|
||||
setRevision((value) => value + 1);
|
||||
} catch (cause) {
|
||||
message.error(cause instanceof Error ? cause.message : "设备保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function rotate(device: OpsDevice) {
|
||||
Modal.confirm({
|
||||
title: "轮换设备密钥?",
|
||||
content: "提交后旧密钥立即失效,新密钥只返回一次并自动下载。请确认设备可以立即更新密钥。",
|
||||
okText: "轮换并下载",
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
const response = await platformApi.rotateOpsDeviceCredential(tenantID, device.id, accessToken);
|
||||
downloadCredential(response.data);
|
||||
message.success(`密钥已轮换至版本 ${response.data.key_version}`);
|
||||
setRevision((value) => value + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function issueClaim(device: OpsDevice) {
|
||||
Modal.confirm({
|
||||
title: "生成新的设备绑定码?",
|
||||
content: "现有未使用绑定码会立即撤销。新绑定码有效期为 24 小时,明文只显示一次。",
|
||||
okText: "生成绑定码",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
const response = await platformApi.issueOpsDeviceClaim(tenantID, device.id, accessToken);
|
||||
showClaim(response.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function remove(device: OpsDevice) {
|
||||
try {
|
||||
await platformApi.deleteOpsDevice(tenantID, device, accessToken);
|
||||
message.success("设备已吊销并解除当前归属");
|
||||
setRevision((value) => value + 1);
|
||||
} catch (cause) {
|
||||
message.error(cause instanceof Error ? cause.message : "设备删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="ops-device-workspace">
|
||||
<Card className="ops-device-toolbar">
|
||||
<div><strong>设备资产与密钥</strong><span>设备 ID 由平台生成;密钥仅在创建或轮换时下载一次,数据库只保存加密密文。</span></div>
|
||||
<Space wrap>
|
||||
<Select value={tenantID} style={{ minWidth: 250 }} onChange={setTenantID} options={accounts.map((item) => ({ value: item.tenant_id, label: item.tenant_name }))} />
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => setRevision((value) => value + 1)}>刷新</Button>
|
||||
{canOperate ? <Button type="primary" icon={<PlusOutlined />} onClick={beginCreate}>新增设备</Button> : null}
|
||||
</Space>
|
||||
</Card>
|
||||
{error ? <Alert type="error" showIcon message="设备列表读取失败" description={error} /> : null}
|
||||
<Card className="ops-device-table-card">
|
||||
<Table rowKey="id" loading={loading} dataSource={devices} pagination={{ pageSize: 10 }} scroll={{ x: 1180 }} columns={[
|
||||
{ title: "设备 ID", dataIndex: "id", width: 300, render: (value: string) => <Typography.Text copyable={{ text: value }} code>{value}</Typography.Text> },
|
||||
{ title: "型号", dataIndex: "model", width: 150, render: (_: unknown, item: OpsDevice) => <><strong>{item.model}</strong><br/><small>{item.hardware_revision || "未填硬件版本"}</small></> },
|
||||
{ title: "家庭 / 长者", width: 190, render: (_: unknown, item: OpsDevice) => item.household_name ? <><strong>{item.household_name}</strong><br/><span>{item.subject_name}</span></> : <Tag>尚未分配</Tag> },
|
||||
{ title: "设备状态", dataIndex: "status", width: 110, render: (value: string) => <Tag color={value === "registered" ? "green" : value === "quarantined" ? "orange" : "red"}>{statusLabel(value)}</Tag> },
|
||||
{ title: "密钥", width: 120, render: (_: unknown, item: OpsDevice) => <><Tag color={item.credential_status === "active" ? "blue" : "red"}>{item.credential_status}</Tag><br/><small>版本 {item.key_version}</small></> },
|
||||
{ title: "最后上报", dataIndex: "last_reported_at", width: 170, render: (value?: string) => value ? new Date(value).toLocaleString() : "从未上报" },
|
||||
{ title: "操作", fixed: "right", width: 320, render: (_: unknown, item: OpsDevice) => canOperate && item.status !== "revoked" ? <Space size="small">
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => beginEdit(item)}>编辑</Button>
|
||||
{!item.household_id && item.status === "registered" ? <Button size="small" icon={<KeyOutlined />} onClick={() => issueClaim(item)}>绑定码</Button> : null}
|
||||
<Button size="small" icon={<SafetyCertificateOutlined />} onClick={() => rotate(item)}>换密钥</Button>
|
||||
<Popconfirm title="删除设备" description="设备将被吊销、密钥失效并解除当前归属;历史记录保留。" okText="确认删除" cancelText="取消" onConfirm={() => remove(item)}><Button size="small" danger icon={<DeleteOutlined />} /></Popconfirm>
|
||||
</Space> : <span>只读</span> }
|
||||
]} />
|
||||
</Card>
|
||||
<Modal open={open} title={editing ? "编辑设备" : "新增设备"} okText={editing ? "保存修改" : "创建并下载密钥"} cancelText="取消" confirmLoading={saving} onOk={() => void save()} onCancel={() => setOpen(false)} width={620}>
|
||||
<Alert type="info" showIcon message={editing ? "设备 ID 和历史密钥版本不可修改" : "平台将自动生成设备 ID 和随机密钥,创建后立即下载密钥文件。"} />
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 18 }}>
|
||||
<Form.Item name="model" label="设备型号" rules={[{ required: true, message: "请输入设备型号" }, { max: 120 }]}><Input /></Form.Item>
|
||||
<Form.Item name="hardware_revision" label="硬件版本"><Input maxLength={80} placeholder="例如 rev-a" /></Form.Item>
|
||||
<Space align="start" style={{ width: "100%" }}>
|
||||
<Form.Item name="minimum_security_version" label="最低安全版本"><Input maxLength={80} /></Form.Item>
|
||||
<Form.Item name="minimum_firmware_version" label="最低固件版本"><Input maxLength={80} /></Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="assignment" label="所属家庭与长者"><Select allowClear placeholder="可暂不分配" options={assignmentOptions} showSearch optionFilterProp="label" /></Form.Item>
|
||||
{editing ? <Form.Item name="status" label="设备状态"><Select options={[{ value: "registered", label: "已登记" }, { value: "quarantined", label: "已隔离" }]} /></Form.Item> : null}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function downloadCredential(issue: DeviceCredentialIssue) {
|
||||
const content = [`device_id=${issue.device_id}`, `device_secret=${issue.secret}`, `key_version=${issue.key_version}`, `issued_at=${issue.issued_at}`, ""].join("\n");
|
||||
const url = URL.createObjectURL(new Blob([content], { type: "text/plain;charset=utf-8" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `device-${issue.device_id}-credential-v${issue.key_version}.txt`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function showClaim(issue: DeviceClaimIssue, title = "一次性设备绑定码") {
|
||||
Modal.success({
|
||||
title,
|
||||
width: 560,
|
||||
okText: "我已保存绑定码",
|
||||
content: <Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Alert type="warning" showIcon message="绑定码明文只显示这一次" description="请通过受控渠道交给家庭用户;再次生成会立即撤销本码。" />
|
||||
<div><Typography.Text type="secondary">设备 ID</Typography.Text><br/><Typography.Text code copyable>{issue.device_id}</Typography.Text></div>
|
||||
<div><Typography.Text type="secondary">绑定码</Typography.Text><br/><Typography.Text code copyable={{ text: issue.claim_code }} style={{ fontSize: 22 }}>{issue.claim_code}</Typography.Text></div>
|
||||
<Typography.Text type="secondary">有效期至 {new Date(issue.expires_at).toLocaleString()}</Typography.Text>
|
||||
</Space>
|
||||
});
|
||||
}
|
||||
|
||||
function statusLabel(value: string) {
|
||||
return ({ registered: "已登记", quarantined: "已隔离", revoked: "已删除" } as Record<string, string>)[value] ?? value;
|
||||
}
|
||||
+501
-248
File diff suppressed because it is too large
Load Diff
+1579
-5
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user