This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#10283a" />
|
||||
<title>长者语音陪伴云平台</title>
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export type OnboardingState = { device_id: string; device_revision: number; binding_generation: number; binding_state: string; activation_state: string; state_hash: string };
|
||||
export type SetupStatus = { setup_id: string; state: OnboardingState; binding_id?: string; claim_id?: string; authenticated: boolean; device_saved: boolean; closed: boolean; sms_enabled: boolean; expires_at_ms: number };
|
||||
export type ProfileOption = { household_id: string; household_name: string; elder_id?: string; preferred_name?: string };
|
||||
export type OnboardingDevice = { platform_device_id: string; device_id: string; activation_state: string; binding_generation: number; household_name?: string; preferred_name?: string; released?: boolean; key_version?: number; pending_key_version?: number; current_attempt_id?: string; enrollment_job_id?: string; activation_id?: string; activation_version?: number; last_error_code?: string };
|
||||
type Envelope<T> = { ok: boolean; code: string; message: string; data: T };
|
||||
export class OnboardingError extends Error { constructor(public code: string, message: string) { super(message); } }
|
||||
const csrfKey = "onboarding.csrf";
|
||||
export function setOnboardingCSRF(value: string) { sessionStorage.setItem(csrfKey, value); }
|
||||
export async function onboardingRequest<T>(path: string, body?: unknown, options: { token?: string; tenant?: string; requestID?: string; method?: "GET" | "POST"; idempotencyKey?: string } = {}): Promise<T> {
|
||||
const method = options.method ?? "POST";
|
||||
const headers: Record<string, string> = { "X-Request-ID": options.requestID ?? crypto.randomUUID() };
|
||||
if (method === "POST") headers["Content-Type"] = "application/json";
|
||||
const csrf = sessionStorage.getItem(csrfKey); if (csrf) headers["X-CSRF-Token"] = csrf;
|
||||
if (options.token) headers.Authorization = `Bearer ${options.token}`;
|
||||
if (options.tenant) headers["X-Tenant-ID"] = options.tenant;
|
||||
if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
|
||||
const response = await fetch(path, { method, headers, credentials: "same-origin", cache: "no-store", referrerPolicy: "no-referrer", body: method === "POST" ? JSON.stringify(body ?? {}) : undefined });
|
||||
let envelope: Envelope<T>;
|
||||
try { envelope = await response.json() as Envelope<T>; } catch { throw new OnboardingError("SERVICE_UNAVAILABLE", "服务暂不可用,请稍后重试"); }
|
||||
if (!response.ok || !envelope.ok) throw new OnboardingError(envelope.code, envelope.message || "操作未完成");
|
||||
return envelope.data;
|
||||
}
|
||||
export const activationLabel = (state: string) => ({ NOT_STARTED: "等待开通", RESERVED: "等待开通", CONTACT_POSSIBLE: "正在开通", REGISTERED_OBSERVED: "等待设备完成测试", READY: "可以开始通话", RECONCILE_REQUIRED: "服务开通待核对", BLOCKED: "服务开通待处理" }[state] ?? state);
|
||||
|
||||
export async function logoutOnboarding() {
|
||||
try { const r = await onboardingRequest<{ csrf_token: string }>("/api/v1/web/account/session", undefined, { method: "GET" }); setOnboardingCSRF(r.csrf_token); } catch { /* An anonymous setup may still have a valid setup CSRF token. */ }
|
||||
const response = await fetch("/api/v1/web/auth/logout", {method:"POST", credentials:"same-origin", headers:{"Content-Type":"application/json", "X-Request-ID":crypto.randomUUID(), "X-CSRF-Token":sessionStorage.getItem(csrfKey) ?? ""},body:"{}"});
|
||||
if (!response.ok && response.status !== 404) throw new Error("退出未完成,请重试");
|
||||
sessionStorage.removeItem(csrfKey);
|
||||
}
|
||||
|
||||
let initialization: Promise<SetupStatus> | undefined;
|
||||
export function initializeOnboarding(): Promise<SetupStatus> {
|
||||
if (initialization) return initialization;
|
||||
// Read the one-time fragment once. React StrictMode must not exchange it twice.
|
||||
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
||||
const token = fragment.get("token"), verifier = fragment.get("verifier");
|
||||
if (window.location.hash || window.location.pathname === "/onboarding/entry") window.history.replaceState({}, "", "/onboarding/setup");
|
||||
initialization = (async () => {
|
||||
if (token && verifier) {
|
||||
try {
|
||||
const result = await onboardingRequest<{ csrf_token: string }>("/api/v1/web/handoffs/exchange", { token, verifier });
|
||||
setOnboardingCSRF(result.csrf_token);
|
||||
} catch (error) {
|
||||
// If the Set-Cookie header arrived but the response body was lost,
|
||||
// bootstrap recovers that session. It never reuses the handoff token.
|
||||
try { const recovered = await onboardingRequest<{ csrf_token: string; data: SetupStatus }>("/api/v1/web/setup/session", undefined, { method: "GET" }); setOnboardingCSRF(recovered.csrf_token); return recovered.data; } catch { throw error; }
|
||||
}
|
||||
}
|
||||
const result = await onboardingRequest<{ csrf_token: string; data: SetupStatus }>("/api/v1/web/setup/session", undefined, { method: "GET" });
|
||||
setOnboardingCSRF(result.csrf_token); return result.data;
|
||||
})();
|
||||
initialization.catch(() => { initialization = undefined; });
|
||||
return initialization;
|
||||
}
|
||||
Vendored
+2784
-2
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import type { User } from "oidc-client-ts";
|
||||
import { APIRequestError, AUTHENTICATION_REQUIRED_EVENT, platformApi, type CurrentUser } from "../api/client";
|
||||
import { completeSigninIfNeeded, startSignin, userManager } from "./oidc";
|
||||
import { logoutOnboarding } from "../api/onboarding";
|
||||
|
||||
type AuthState = {
|
||||
loading: boolean;
|
||||
@@ -86,7 +87,7 @@ export function useAuth() {
|
||||
return {
|
||||
...state,
|
||||
login: () => startSignin(),
|
||||
logout: () => userManager.signoutRedirect(),
|
||||
logout: async () => { await logoutOnboarding(); await userManager.signoutRedirect(); },
|
||||
retry: () => loadUser(state.oidcUser)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ClockCircleOutlined, DollarOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
APIRequestError,
|
||||
@@ -87,12 +87,18 @@ function UsageOverviewView({ audience, overview, loading, error, periodDays, onP
|
||||
const [metricMode, setMetricMode] = useState<MetricMode>("duration");
|
||||
const [densityMode, setDensityMode] = useState<DensityMode>("daily");
|
||||
const [selectedDate, setSelectedDate] = useState<string>();
|
||||
const heatmapScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!overview?.cost_available && metricMode === "cost") setMetricMode("duration");
|
||||
}, [metricMode, overview?.cost_available]);
|
||||
|
||||
const heatmap = useMemo(() => buildHeatmap(overview, metricMode, densityMode), [densityMode, metricMode, overview]);
|
||||
useEffect(() => {
|
||||
if (audience === "family" && window.matchMedia("(max-width: 600px), (pointer: coarse)").matches && heatmapScrollRef.current) {
|
||||
heatmapScrollRef.current.scrollLeft = heatmapScrollRef.current.scrollWidth;
|
||||
}
|
||||
}, [audience, overview?.from, overview?.to]);
|
||||
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" ? [
|
||||
@@ -125,8 +131,8 @@ function UsageOverviewView({ audience, overview, loading, error, periodDays, onP
|
||||
|
||||
<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" style={{ gridTemplateColumns: `repeat(${heatmap.weeks}, 11px)` }}>{heatmap.months.map((item) => <span key={item.key} style={{ gridColumnStart: item.week }}>{item.label}</span>)}</div>
|
||||
<div className="usage-heatmap-scroll" ref={heatmapScrollRef} role="region" aria-label="聊天活动日历,可横向滚动" tabIndex={0}>
|
||||
<div className="usage-months" style={{ gridTemplateColumns: `repeat(${heatmap.weeks}, var(--usage-heatmap-cell, 11px))` }}>{heatmap.months.map((item) => <span key={item.key} style={{ gridColumnStart: item.week }}>{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>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/* Customer-only responsive rules follow the shared desktop themes. */
|
||||
.customer-account-name { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.customer-mobile-menu .ant-drawer-header { padding-top: max(16px, env(safe-area-inset-top)); }
|
||||
.customer-mobile-menu .ant-drawer-body { padding: 12px 12px max(20px, env(safe-area-inset-bottom)); }
|
||||
.customer-mobile-menu .ant-drawer-close { min-width: 44px; min-height: 44px; padding: 0; border: 0 !important; color: #4a4947 !important; background: transparent !important; }
|
||||
.customer-mobile-menu .ant-menu { border-inline-end: 0; }
|
||||
.customer-mobile-menu .ant-menu-item,
|
||||
.customer-mobile-menu .ant-menu-submenu-title { min-height: 44px; height: auto; line-height: 44px; }
|
||||
.customer-mobile-search { margin-bottom: 16px; }
|
||||
.customer-mobile-search .ant-input-affix-wrapper, .customer-mobile-search .ant-btn { min-height: 44px; }
|
||||
.customer-mobile-search input.ant-input { min-height: 0 !important; border: 0 !important; padding: 0; background: transparent !important; }
|
||||
.customer-mobile-menu__note { margin: 16px; color: #777; font-size: 13px; }
|
||||
.customer-profile { grid-template-columns: minmax(0, 1fr); }
|
||||
.customer-profile > *, .customer-profile .ant-card { min-width: 0; }
|
||||
.conversation-mobile-filter-toggle, .conversation-return-list { display: none; }
|
||||
.customer-layout :is(input, select, textarea), .customer-mobile-menu input { min-width: 0; max-width: 100%; }
|
||||
.customer-layout :is(audio, video, img) { max-width: 100%; }
|
||||
.customer-layout :is(.workspace, .workspace__context-bar, .workspace__actions, .panel-section, .tool-form, .profile-editor, .workspace-dialog, .subject-create-modal, .subject-detail-modal) { min-width: 0; }
|
||||
.customer-layout :is(.data-row, .subject-data-row, .info, .workspace-dialog, .subject-detail-modal, .customer-profile, .workspace__inline-error, .workspace__action-message) { overflow-wrap: anywhere; }
|
||||
.customer-layout :is(.permission-matrix-wrap, .usage-heatmap-scroll, .household-tabs, .elder-switcher) { max-width: 100%; overscroll-behavior-x: contain; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.customer-page--conversations .memory-timeline-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.customer-page--conversations .memory-model-card { grid-column: auto; }
|
||||
.customer-page .conversation-return-list { display: block; margin-bottom: 16px; }
|
||||
.customer-page :is(.memory-summary-panel, .conversation-list) { scroll-margin-top: 88px; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.customer-layout { min-height: 100dvh; }
|
||||
.customer-header { padding-left: max(12px, env(safe-area-inset-left)) !important; padding-right: max(12px, env(safe-area-inset-right)) !important; }
|
||||
.customer-header__brand { min-width: 0; gap: 6px; }
|
||||
.customer-header__actions { gap: 4px; min-width: 0; }
|
||||
.customer-header .customer-menu-toggle,
|
||||
.customer-header__actions > .ant-badge .ant-btn,
|
||||
.customer-header .customer-account-button { min-width: 44px; height: 44px; padding: 4px; }
|
||||
.customer-header .customer-account-button > .ant-avatar { display: inline-flex; flex: none; }
|
||||
.customer-account-button > .customer-account-name,
|
||||
.customer-account-button > .anticon { display: none; }
|
||||
.customer-header__brand > strong { display: block; white-space: nowrap; font-size: 18px !important; }
|
||||
.customer-header .customer-brand-mark { flex: none; width: 28px !important; height: 28px !important; font-size: 21px; }
|
||||
.customer-layout .customer-content { width: 100%; margin-left: 0 !important; }
|
||||
.customer-page.customer-page { padding-left: max(16px, env(safe-area-inset-left)) !important; padding-right: max(16px, env(safe-area-inset-right)) !important; padding-bottom: max(32px, env(safe-area-inset-bottom)) !important; }
|
||||
.customer-page .workspace__context-bar { gap: 12px; align-items: stretch; }
|
||||
.customer-page .workspace__context-bar .household-tabs { flex: 0 1 auto; width: 100%; min-height: 48px; padding-block: 3px !important; overflow-x: auto; }
|
||||
.customer-page .household-tabs button { max-width: min(280px, 80vw); overflow: hidden; text-overflow: ellipsis; }
|
||||
.customer-page .workspace__actions { flex: 0 1 auto; flex-wrap: wrap !important; width: 100% !important; justify-content: flex-start !important; }
|
||||
.customer-page .workspace-story-subject { flex: 1 1 160px; grid-template-columns: auto minmax(0, 1fr); width: auto !important; }
|
||||
.customer-page .workspace-story-subject select { width: 100% !important; min-width: 0 !important; }
|
||||
.customer-page :is(.subject-card-grid, .story-candidate-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||
.customer-page :is(.privacy-toolbar, .subject-management-operations__forms, .recording-panel__body) { grid-template-columns: minmax(0, 1fr) !important; }
|
||||
.customer-page .privacy-actions { flex-wrap: wrap; }
|
||||
.customer-page .subject-management-operations[open] { width: calc(100% - 24px); max-height: calc(100dvh - 24px); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.customer-page.customer-page { padding-top: 0 !important; }
|
||||
.customer-page > .customer-page__header { display: block; padding: 20px 0 16px !important; }
|
||||
.customer-page .customer-page__header h1 { font-size: 24px !important; }
|
||||
.customer-page .customer-page__header p { line-height: 1.65; }
|
||||
.customer-page :is(.workspace__header, .section-title, .subject-list-toolbar, .story-board__section-title, .family-overview__title-row) { flex-wrap: wrap; gap: 10px; }
|
||||
.customer-page :is(.workspace__actions, .row-actions, .privacy-actions) > button { flex: 1 1 auto; white-space: normal; height: auto !important; }
|
||||
.customer-page :is(.subject-card-grid, .story-candidate-grid, .confirmed-memory-grid, .family-overview__aside, .subject-management-operations__forms, .conversation-readiness__checks, .story-review-fields, .profile-editor, .tool-form, .subject-create-form, .recording-access-form) { grid-template-columns: minmax(0, 1fr) !important; }
|
||||
.customer-page :is(.privacy-counts, .workspace__summary) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.customer-page :is(.tool-form, .profile-editor) { padding: 14px; }
|
||||
.customer-page :is(.tool-form, .profile-editor) > * { min-width: 0; grid-column: auto; }
|
||||
.customer-page :is(.tool-form__wide, .profile-editor__wide) { grid-column: 1 / -1; }
|
||||
.customer-page .subject-profile-card { padding: 16px !important; gap: 12px; }
|
||||
.customer-page .subject-profile-card__status { grid-column: 2; justify-self: start; }
|
||||
.customer-page .subject-profile-card__avatar { grid-row: 1 / 3; }
|
||||
.customer-page .subject-profile-card__facts { grid-column: 1 / -1; }
|
||||
.customer-page .subject-list-toolbar { align-items: stretch; }
|
||||
.customer-page .subject-list-toolbar > label { width: 100%; min-width: 0; }
|
||||
.customer-page :is(.data-row, .subject-data-row) { flex-wrap: wrap; gap: 12px; padding: 14px !important; }
|
||||
.customer-page :is(.data-row, .subject-data-row) > div { min-width: 0; }
|
||||
.customer-page .row-actions { flex-wrap: wrap; width: 100%; }
|
||||
.customer-page .resource-detail__head { flex-direction: column; align-items: stretch; }
|
||||
.customer-page .resource-detail__head h3 { font-size: 20px !important; line-height: 1.5 !important; }
|
||||
.customer-page .story-source-actions { flex-wrap: wrap; }
|
||||
.customer-page .device-binding-actions { flex-direction: column; align-items: stretch; }
|
||||
.customer-page :is(.sensitive-access-request__head, .sensitive-access-request__actions, .privacy-delete-confirmation__head, .privacy-delete-confirmation__actions) { flex-wrap: wrap; }
|
||||
.customer-page .sensitive-access-request { grid-template-columns: minmax(0, 1fr); }
|
||||
.customer-page .customer-profile__identity { flex-wrap: wrap; gap: 12px; }
|
||||
.customer-page .customer-profile__identity > div { min-width: 0; flex: 1 1 140px; }
|
||||
.customer-page .customer-profile__identity h2 { font-size: 22px; }
|
||||
.customer-page .customer-profile__identity > button { width: 100%; }
|
||||
.customer-page .ant-card-body { padding: 16px; }
|
||||
.customer-page .ant-card-head { padding-inline: 16px; }
|
||||
.customer-page .ant-card-head-title { white-space: normal; }
|
||||
.customer-page .customer-login-card { margin-block: 12px; padding: 0; }
|
||||
.customer-page .customer-login-notes { flex-wrap: wrap; justify-content: center; }
|
||||
.customer-page .ant-alert { flex-wrap: wrap; }
|
||||
.customer-page .ant-alert-actions { margin-inline-start: 0; width: 100%; }
|
||||
.customer-page .permission-matrix { min-width: 520px; }
|
||||
.customer-page .permission-matrix th:first-child { position: sticky; left: 0; z-index: 1; background: #fff; }
|
||||
.customer-page .memory-timeline-grid .conversation-list { max-height: none; }
|
||||
.customer-page .memory-timeline-scroll { max-height: min(440px, 60dvh); }
|
||||
.customer-page .memory-summary-panel { scroll-margin-top: 78px; }
|
||||
.customer-page :is(.memory-summary-panel__header, .conversation-detail__header, .recording-panel__header) { flex-wrap: wrap; gap: 10px; }
|
||||
.customer-page .memory-timeline-grid .conversation-filter__fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.customer-page .conversation-filter { grid-template-columns: minmax(0, 1fr); }
|
||||
.customer-page .conversation-mobile-filter-toggle { display: block; }
|
||||
.customer-page .conversation-filter--expanded .conversation-filter__fields > label { display: grid !important; }
|
||||
.customer-page .journal-weekly__chart { grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; padding-inline: 8px; overflow: visible; }
|
||||
.customer-page .journal-weekly__chart > div { min-width: 0; }
|
||||
.customer-page .journal-card > header { flex-wrap: wrap; }
|
||||
.customer-page .reminder-todo { grid-template-columns: 8px minmax(0, 1fr) !important; }
|
||||
.customer-page .reminder-todo small { grid-column: 2; }
|
||||
.customer-page :is(.reminder-todos, .reminder-history__summary) { margin-inline: 12px; }
|
||||
.customer-page .usage-overview { --usage-heatmap-cell: 24px; margin-inline: 0; }
|
||||
.customer-page .usage-recent-list article { flex-wrap: wrap; padding-inline: 14px; }
|
||||
.customer-page .usage-recent-list time { flex-basis: 100%; }
|
||||
.customer-page .usage-density-tabs button { min-height: 44px !important; padding-inline: 12px !important; }
|
||||
|
||||
/* Keep the entire form reachable with browser chrome and the keyboard open. */
|
||||
.customer-layout :is(.workspace-dialog-backdrop, .subject-modal-backdrop) { padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); align-items: end; }
|
||||
.customer-layout :is(.workspace-dialog, .subject-create-modal, .subject-detail-modal) { width: 100%; min-width: 0; max-width: 100%; max-height: calc(100dvh - max(8px, env(safe-area-inset-top)) - max(8px, env(safe-area-inset-bottom))); overflow: auto; overscroll-behavior: contain; border-radius: 16px !important; }
|
||||
.customer-layout :is(.workspace-dialog__grid, .subject-form-grid, .subject-create-modal__grid, .subject-detail-modal__grid, .privacy-delete-confirmation) { grid-template-columns: minmax(0, 1fr); }
|
||||
.customer-layout :is(.workspace-dialog, .subject-create-modal, .subject-detail-modal) > header { position: sticky; top: 0; z-index: 2; padding: 14px 16px; background: #fff; }
|
||||
.customer-layout :is(.workspace-dialog__body, .subject-create-modal__body, .subject-detail-modal__body) { padding: 16px; }
|
||||
.customer-layout :is(.workspace-dialog, .subject-create-modal, .subject-detail-modal) > footer { flex-wrap: wrap; padding: 14px 16px; }
|
||||
.customer-layout :is(.workspace-dialog, .subject-create-modal, .subject-detail-modal) > footer button { flex: 1 1 110px; min-width: 0; white-space: normal; }
|
||||
.customer-layout .family-controlled-operation.workspace-dialog { padding: 16px; }
|
||||
.customer-layout .workspace-dialog__body > * { min-width: 0; }
|
||||
.customer-layout :is(.subject-create-modal__row, .subject-profile-editor, .dialogue-presets > div:last-child, .dialogue-presets--editor) { grid-template-columns: minmax(0, 1fr); }
|
||||
.customer-layout .subject-detail-modal__body { max-height: none; overflow: visible; }
|
||||
.customer-layout .subject-detail-modal__header { flex-wrap: wrap; }
|
||||
.customer-layout .subject-profile-completion { order: 3; }
|
||||
.customer-layout .subject-detail-modal__header > button { margin-left: auto; }
|
||||
.customer-layout :is(.workspace-dialog, .subject-create-modal, .subject-detail-modal) :is(input, select, textarea) { width: 100%; }
|
||||
.customer-layout :is(.workspace-dialog, .subject-create-modal, .subject-detail-modal) :is(input[type="checkbox"], input[type="radio"]) { width: auto; }
|
||||
}
|
||||
|
||||
@media (pointer: coarse), (max-width: 600px) {
|
||||
.customer-layout :is(button, .ant-btn, summary), .customer-mobile-menu .ant-btn { min-height: 44px !important; touch-action: manipulation; }
|
||||
.customer-layout .household-tabs button { height: 44px !important; }
|
||||
.customer-layout :is(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea),
|
||||
.customer-mobile-menu input { font-size: 16px !important; min-height: 44px !important; }
|
||||
.customer-layout input[type="range"] { min-height: 44px; }
|
||||
.customer-layout :is(input[type="checkbox"], input[type="radio"]) { width: 20px; height: 20px; flex-shrink: 0; }
|
||||
.customer-layout :is(.family-icon-button, .subject-detail-modal__header > button, .subject-create-modal header > button, .workspace-dialog > header > button) { min-width: 44px; }
|
||||
.customer-page .subject-tag-editor__chips > span button { min-width: 32px; min-height: 32px !important; }
|
||||
.customer-page .subject-tag-editor__input button { width: 44px; right: 2px; }
|
||||
.customer-page .usage-overview { --usage-heatmap-cell: 24px; }
|
||||
.customer-page .usage-heatmap-wrap { min-width: max-content; }
|
||||
.customer-page .usage-heatmap { grid-auto-columns: 24px; grid-template-rows: repeat(7, 24px); gap: 4px; }
|
||||
.customer-page .usage-heatmap :is(button, span) { width: 24px; height: 24px; min-height: 24px !important; }
|
||||
.customer-page .usage-weekdays { grid-template-rows: repeat(3, 48px); gap: 8px; }
|
||||
}
|
||||
|
||||
html:has(.customer-layout :is(.workspace-dialog-backdrop, .subject-modal-backdrop, .subject-management-operations[open])) { overflow: hidden; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.customer-layout .customer-content { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMediaQuery(query: string) {
|
||||
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
const sync = () => setMatches(media.matches);
|
||||
sync();
|
||||
media.addEventListener("change", sync);
|
||||
return () => media.removeEventListener("change", sync);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
+3
-1
@@ -4,8 +4,10 @@ import { ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import { CustomerPortal } from "./pages/CustomerPortal";
|
||||
import { MaintenancePortal } from "./pages/MaintenancePortal";
|
||||
import { OnboardingPortal } from "./pages/OnboardingPortal";
|
||||
import "./styles.css";
|
||||
import "./cal-theme.css";
|
||||
import "./customer-responsive.css";
|
||||
|
||||
function PortalRouter() {
|
||||
const [pathname, setPathname] = useState(() => window.location.pathname);
|
||||
@@ -16,7 +18,7 @@ function PortalRouter() {
|
||||
return () => window.removeEventListener("popstate", syncPathname);
|
||||
}, []);
|
||||
|
||||
const Portal = pathname.startsWith("/maintenance") ? MaintenancePortal : CustomerPortal;
|
||||
const Portal = pathname.startsWith("/onboarding") ? OnboardingPortal : pathname.startsWith("/maintenance") ? MaintenancePortal : CustomerPortal;
|
||||
return <Portal />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.onboarding-page{min-height:100dvh;background:#f6f8fa;padding:28px 18px 48px;color:#17252b}.onboarding-shell{max-width:640px;margin:0 auto}.onboarding-header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:32px}.onboarding-brand{font-size:18px;font-weight:650;display:flex;gap:10px;align-items:center}.onboarding-page h1{font-size:28px;line-height:1.35;margin:0 0 12px}.onboarding-intro{color:#5f6f78;line-height:1.8;margin-bottom:26px}.onboarding-page .ant-card{margin:20px 0}.onboarding-page .ant-form{margin-top:22px}.onboarding-page .ant-btn-block+.ant-btn-block{margin-top:12px}.onboarding-device{font-size:13px;color:#61737b;margin:24px 0 0;overflow-wrap:anywhere}.onboarding-note{font-size:12px;line-height:1.7;color:#61737b}.onboarding-alert{margin-bottom:20px}.onboarding-service{display:flex;gap:14px;background:#f2f8f5;border-radius:12px;padding:20px;margin-bottom:24px}.onboarding-service>.anticon{font-size:24px;color:#308065}.onboarding-service p{margin:7px 0 0;color:#50645b;line-height:1.7}.onboarding-footer{margin-top:34px;color:#708089;font-size:12px}.onboarding-loading{padding:70px;text-align:center}.onboarding-page a{color:#215e56}.onboarding-page .ant-input,.onboarding-page .ant-select{font-size:16px}.onboarding-page .ant-steps{padding:0 4px}.onboarding-page .ant-result{padding:24px 0}.onboarding-page .ant-form-item-label label{height:auto;white-space:normal}@media(max-width:480px){.onboarding-page{padding:20px 14px 36px}.onboarding-page h1{font-size:24px}.onboarding-header{margin-bottom:26px}.onboarding-page .ant-card-body{padding:20px}.onboarding-page .ant-steps{max-width:100%;overflow:hidden}.onboarding-page .ant-result-title{font-size:22px}}
|
||||
|
||||
/* Keep the extra maintenance destination readable in the shared header. */
|
||||
@media(min-width:1101px){.maintenance-top-nav{min-width:0;overflow-x:auto;gap:20px;margin-left:28px;align-items:center}.maintenance-top-nav button{flex:0 0 auto;white-space:nowrap;height:100%}}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Input,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { useMediaQuery } from "../hooks/useMediaQuery";
|
||||
import { SaaSWorkspace, type CustomerSection } from "./SaaSWorkspace";
|
||||
|
||||
type PortalSection = CustomerSection | "profile";
|
||||
@@ -66,18 +68,22 @@ const sectionMeta: Record<PortalSection, { title: string; description: string; b
|
||||
|
||||
export function CustomerPortal() {
|
||||
const auth = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720);
|
||||
const isMobile = useMediaQuery("(max-width: 900px)");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
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());
|
||||
const onPopState = () => { setSection(sectionFromURL()); setMobileMenuOpen(false); };
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { setMobileMenuOpen(false); }, [isMobile]);
|
||||
|
||||
const menuItems = useMemo<MenuProps["items"]>(() => [
|
||||
{ key: "home", icon: <HomeOutlined />, label: "首页" },
|
||||
{ key: "conversations", icon: <CommentOutlined />, label: "聊天记录" },
|
||||
@@ -102,11 +108,12 @@ export function CustomerPortal() {
|
||||
|
||||
function navigate(next: PortalSection) {
|
||||
setSection(next);
|
||||
if (window.innerWidth <= 720) setCollapsed(true);
|
||||
setMobileMenuOpen(false);
|
||||
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);
|
||||
window.scrollTo({ top: 0, behavior: "instant" });
|
||||
}
|
||||
|
||||
function search() {
|
||||
@@ -135,17 +142,17 @@ export function CustomerPortal() {
|
||||
return <Layout className="customer-layout">
|
||||
<Layout.Header className="customer-header">
|
||||
<div className="customer-header__brand">
|
||||
<Button className="customer-menu-toggle" type="text" icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} aria-label={collapsed ? "展开菜单" : "收起菜单"} onClick={() => setCollapsed((value) => !value)} />
|
||||
<Button className="customer-menu-toggle" type="text" icon={(isMobile ? !mobileMenuOpen : collapsed) ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />} aria-label={isMobile ? "打开导航菜单" : collapsed ? "展开菜单" : "收起菜单"} aria-expanded={isMobile ? mobileMenuOpen : !collapsed} aria-controls={isMobile ? "customer-mobile-navigation" : "customer-desktop-navigation"} onClick={() => isMobile ? setMobileMenuOpen(true) : setCollapsed((value) => !value)} />
|
||||
<span className="customer-brand-mark" aria-hidden="true"><HeartOutlined /></span>
|
||||
<strong>安心陪伴</strong>
|
||||
</div>
|
||||
<div className="customer-header__actions">
|
||||
<Input.Search value={searchValue} onChange={(event) => setSearchValue(event.target.value)} onSearch={search} prefix={<SearchOutlined />} placeholder="搜索长者、设备、通话" allowClear className="customer-search" />
|
||||
{!isMobile ? <Input.Search value={searchValue} onChange={(event) => setSearchValue(event.target.value)} onSearch={search} prefix={<SearchOutlined />} placeholder="搜索长者、设备、通话" allowClear className="customer-search" /> : null}
|
||||
<Tooltip title="提醒与待办"><Badge dot><Button type="text" icon={<BellOutlined />} aria-label="打开提醒与待办" onClick={() => navigate("messages")} /></Badge></Tooltip>
|
||||
{auth.currentUser || designPreview ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight">
|
||||
<Button type="text" className="customer-account-button">
|
||||
<Button type="text" className="customer-account-button" aria-label="打开账户菜单">
|
||||
<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>
|
||||
<span className="customer-account-name">{designPreview ? "陈强" : auth.currentUser!.identity.display_name || auth.currentUser!.identity.preferred_username}</span>
|
||||
<CaretDownOutlined />
|
||||
</Button>
|
||||
</Dropdown> : <Button type="primary" onClick={() => void auth.login()}>登录</Button>}
|
||||
@@ -153,11 +160,11 @@ export function CustomerPortal() {
|
||||
</Layout.Header>
|
||||
|
||||
<Layout className="customer-layout__body">
|
||||
<Layout.Sider className="customer-sider" width={224} collapsedWidth={72} collapsed={collapsed} trigger={null}>
|
||||
{!isMobile ? <Layout.Sider id="customer-desktop-navigation" className="customer-sider" width={224} collapsedWidth={72} collapsed={collapsed} trigger={null}>
|
||||
<div className="customer-sider__title"><HeartOutlined />{collapsed ? null : <span>长者陪伴</span>}</div>
|
||||
<Menu mode="inline" selectedKeys={[section]} defaultOpenKeys={["privacy-group"]} items={menuItems} onClick={({ key }) => navigate(key as PortalSection)} />
|
||||
<div className="customer-sider__help"><SafetyCertificateOutlined />{collapsed ? null : <div><strong>隐私受保护</strong><span>仅展示当前家庭数据</span></div>}</div>
|
||||
</Layout.Sider>
|
||||
</Layout.Sider> : null}
|
||||
|
||||
<Layout.Content className="customer-content">
|
||||
<main className={`customer-page customer-page--${section}`}>
|
||||
@@ -176,6 +183,13 @@ export function CustomerPortal() {
|
||||
</main>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
<Drawer title="安心陪伴" placement="left" open={isMobile && mobileMenuOpen} onClose={() => setMobileMenuOpen(false)} rootClassName="customer-mobile-menu" size="min(320px, calc(100vw - 32px))">
|
||||
<nav id="customer-mobile-navigation" aria-label="用户端导航">
|
||||
<Input.Search value={searchValue} onChange={(event) => setSearchValue(event.target.value)} onSearch={search} placeholder="搜索长者、设备、通话" aria-label="搜索长者、设备、通话" allowClear className="customer-mobile-search" />
|
||||
<Menu mode="inline" selectedKeys={[section]} defaultOpenKeys={["privacy-group"]} items={menuItems} onClick={({ key }) => navigate(key as PortalSection)} />
|
||||
<p className="customer-mobile-menu__note"><SafetyCertificateOutlined /> 仅展示当前家庭数据</p>
|
||||
</nav>
|
||||
</Drawer>
|
||||
</Layout>;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,10 +44,11 @@ import { OpsBillingWorkspace } from "./OpsBillingWorkspace";
|
||||
import { OpsConfigWorkspace } from "./OpsConfigWorkspace";
|
||||
import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace";
|
||||
import { OpsDeviceWorkspace } from "./OpsDeviceWorkspace";
|
||||
import { OpsOnboardingWorkspace } from "./OpsOnboardingWorkspace";
|
||||
import { OpsFirmwareWorkspace } from "./OpsFirmwareWorkspace";
|
||||
import { OpsContentModelWorkspace } from "./OpsContentModelWorkspace";
|
||||
|
||||
type MaintenanceArea = "overview" | "devices" | "firmware" | "config" | "models" | "quality" | "billing" | "alerts" | "diagnostics";
|
||||
type MaintenanceArea = "overview" | "devices" | "onboarding" | "firmware" | "config" | "models" | "quality" | "billing" | "alerts" | "diagnostics";
|
||||
|
||||
type HealthState = {
|
||||
loading: boolean;
|
||||
@@ -60,6 +61,7 @@ type HealthState = {
|
||||
};
|
||||
|
||||
const areaMeta: Record<MaintenanceArea, { title: string; description: string; icon: ReactNode }> = {
|
||||
onboarding: { title: "首次接入", description: "生产放行、设备认证、按键绑定与激活对账。", icon: <ApiOutlined /> },
|
||||
overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> },
|
||||
devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> },
|
||||
firmware: { title: "固件管理", description: "维护租户级固件版本、可信签名、公私有上传下载与分发生命周期。", icon: <CloudUploadOutlined /> },
|
||||
@@ -201,6 +203,7 @@ export function MaintenancePortal() {
|
||||
{auth.currentUser && auth.oidcUser && hasOperationsAccess ? <section className="maintenance-workspace">
|
||||
{area === "overview" ? <MaintenanceOverview health={health} accounts={operationsAccounts} /> : null}
|
||||
{area === "devices" ? <OpsDeviceWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
{area === "onboarding" ? <OpsOnboardingWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
{area === "firmware" ? <OpsFirmwareWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
{area === "config" ? <OpsConfigWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
{area === "models" ? <OpsContentModelWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Alert, Button, Card, Form, Input, Result, Select, Space, Spin, Steps, Tag } from "antd";
|
||||
import { CheckCircleOutlined, PhoneOutlined } from "@ant-design/icons";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { activationLabel, initializeOnboarding, logoutOnboarding, OnboardingError, onboardingRequest, setOnboardingCSRF, type OnboardingDevice, type ProfileOption, type SetupStatus } from "../api/onboarding";
|
||||
import "../onboarding.css";
|
||||
|
||||
export function OnboardingPortal() {
|
||||
const auth = useAuth();
|
||||
const listMode = window.location.pathname === "/onboarding/devices";
|
||||
const [status, setStatus] = useState<SetupStatus>();
|
||||
const [devices, setDevices] = useState<OnboardingDevice[]>([]);
|
||||
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
|
||||
const [selected, setSelected] = useState<string>();
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initial, setInitial] = useState(true);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [smsID, setSMSID] = useState("");
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [phone, setPhone] = useState("+86");
|
||||
const [code, setCode] = useState("");
|
||||
const linked = useRef(false);
|
||||
const acknowledged = useRef("");
|
||||
const pendingRequest = useRef<Record<string, { body: string; id: string }>>({});
|
||||
// Retain request IDs across manual retry after a network failure.
|
||||
function requestID(action: string, body: unknown) { const text = JSON.stringify(body); if (pendingRequest.current[action]?.body !== text) pendingRequest.current[action] = { body: text, id: crypto.randomUUID() }; return pendingRequest.current[action].id; }
|
||||
const refresh = useCallback(async () => {
|
||||
if (listMode) { const data = await onboardingRequest<{ items: OnboardingDevice[] }>("/api/v1/web/onboarding/devices", undefined, { method: "GET", token: auth.oidcUser?.access_token }); setDevices(data.items); }
|
||||
else { try { const data = await onboardingRequest<SetupStatus>("/api/v1/web/setup/status"); setStatus(data); } catch (error) { if (!(error instanceof OnboardingError) || !["RESOURCE_ACCESS_DENIED", "WEB_SESSION_EXPIRED"].includes(error.code)) throw error; const result = await onboardingRequest<{ csrf_token: string; data: SetupStatus }>("/api/v1/web/setup/session", undefined, { method: "GET" }); setOnboardingCSRF(result.csrf_token); setStatus(result.data); } }
|
||||
}, [listMode, auth.oidcUser?.access_token]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (listMode) { setInitial(false); return; }
|
||||
void initializeOnboarding().then(data => { if (active) setStatus(data); }).catch(e => { if (active) setError(e.message); }).finally(() => { if (active) setInitial(false); });
|
||||
return () => { active = false; };
|
||||
}, [listMode]);
|
||||
useEffect(() => {
|
||||
if (initial || (!listMode && !status)) return;
|
||||
let active = true, timer: ReturnType<typeof setTimeout>;
|
||||
const poll = async () => { try { await refresh(); if (active) setError(""); } catch (e) { if (active) setError((e as Error).message); } finally { if (active) timer = setTimeout(poll, listMode || status?.state.binding_state === "BOUND" ? 10000 : 2000); } };
|
||||
timer = setTimeout(poll, listMode ? 0 : 2000); return () => { active = false; clearTimeout(timer); };
|
||||
}, [initial, listMode, !!status, status?.state.binding_state, refresh]);
|
||||
useEffect(() => { if (countdown <= 0) return; const timer = setTimeout(() => setCountdown(v => v - 1), 1000); return () => clearTimeout(timer); }, [countdown]);
|
||||
useEffect(() => { if (!status?.authenticated || status.closed || status.state.binding_state === "BOUND") return; void onboardingRequest<{ items: ProfileOption[] }>("/api/v1/web/profiles").then(r => { setProfiles(r.items); setSelected(v => v ?? r.items.find(p => p.elder_id)?.elder_id); }).catch(e => setError(e.message)); }, [status?.authenticated, status?.closed, status?.state.binding_state]);
|
||||
useEffect(() => {
|
||||
if (!status || status.authenticated || status.closed || !auth.oidcUser || linked.current) return;
|
||||
linked.current = true;
|
||||
void onboardingRequest<{ csrf_token: string }>("/api/v1/web/setup/auth-link", {}, { token: auth.oidcUser.access_token }).then(r => { setOnboardingCSRF(r.csrf_token); return refresh(); }).catch(e => { setError(e.message); linked.current = false; });
|
||||
}, [status?.authenticated, status?.closed, !!status, auth.oidcUser, refresh]);
|
||||
useEffect(() => {
|
||||
if (!status?.binding_id || status.closed || acknowledged.current === status.binding_id) return;
|
||||
const body = { binding_id: status.binding_id, binding_generation: status.state.binding_generation, received_revision: status.state.device_revision };
|
||||
void onboardingRequest("/api/v1/web/setup/ack", body, { requestID: requestID("ack", body) }).then(() => { acknowledged.current = status.binding_id!; }).catch(e => setError(e.message));
|
||||
}, [status?.binding_id, status?.closed, status?.state.device_revision]);
|
||||
async function run(action: () => Promise<void>) { setBusy(true); setError(""); try { await action(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }
|
||||
const bound = status?.state.binding_state === "BOUND", pending = status?.state.binding_state === "CLAIM_PENDING";
|
||||
useEffect(() => { if (status?.state.binding_state === "UNBOUND") delete pendingRequest.current.claim; }, [status?.state.binding_state]);
|
||||
const step = bound ? 3 : pending ? 2 : status?.authenticated ? 1 : 0;
|
||||
return <main className="onboarding-page"><div className="onboarding-shell">
|
||||
<header className="onboarding-header"><span className="onboarding-brand"><PhoneOutlined /> AI 电话</span><a href="/onboarding/devices">我的设备</a></header>
|
||||
<h1>{listMode ? "我的陪伴设备" : bound ? "设备已绑定" : "连接家人的陪伴设备"}</h1>
|
||||
<p className="onboarding-intro">{listMode ? "查看设备绑定和服务开通进度。" : bound ? "绑定已完成,可以继续查看设备保存和服务开通进度。" : "登录账号,选择使用者,再到设备旁按键确认。"}</p>
|
||||
{error && <Alert type="warning" showIcon title={error} className="onboarding-alert" action={<Button size="small" onClick={() => void run(async () => { if (!status && !listMode) setStatus(await initializeOnboarding()); else await refresh(); })}>刷新</Button>} />}
|
||||
{initial ? <div className="onboarding-loading"><Spin size="large" aria-label="正在恢复设备设置" /></div> : listMode ? <>
|
||||
{!devices.length ? <Card><p>登录后可查看已绑定的设备。新设备请从设备上的设置入口开始。</p><Button onClick={() => void auth.login()}>使用账号登录</Button><Button onClick={() => void run(refresh)}>刷新设备</Button></Card> : devices.map(device => <Card key={device.platform_device_id} title={device.preferred_name || "陪伴设备"}><p>{device.household_name} · {device.device_id}</p><Tag color={device.activation_state === "READY" ? "success" : "processing"}>{activationLabel(device.activation_state)}</Tag></Card>)}
|
||||
</> : status ? <>
|
||||
<Steps current={step} size="small" responsive items={[{ title: "登录" }, { title: "选择使用者" }, { title: "设备确认" }, { title: "绑定完成" }]} />
|
||||
<p className="onboarding-device">当前设备:{status.state.device_id}</p>
|
||||
{bound ? <Card><Result status="success" title="绑定已完成" subTitle={status.device_saved ? "设备已保存绑定结果。" : "绑定已保存在云端,正在等待设备保存。"} /><div className="onboarding-service"><CheckCircleOutlined /><div><strong>{activationLabel(status.state.activation_state)}</strong><p>{status.state.activation_state === "READY" ? "可以在设备上发起通话。" : "服务开通与绑定分别处理,无需重新配网或绑定。"}</p></div></div><Button type="primary" block href="/onboarding/devices">完成,查看我的设备</Button></Card>
|
||||
: status.closed ? <Card><Result status="info" title="设置入口已结束" subTitle="设备身份和已完成的操作会保留。请从设备重新打开设置入口。" /></Card>
|
||||
: !status.authenticated ? <Card title="登录并继续绑定">
|
||||
<Button type="primary" block loading={auth.loading || busy} onClick={() => void auth.login()}>使用已有账号登录</Button>
|
||||
{auth.oidcUser && <Button block onClick={() => void run(async () => { const r = await onboardingRequest<{ csrf_token: string }>("/api/v1/web/setup/auth-link", {}, { token: auth.oidcUser?.access_token }); setOnboardingCSRF(r.csrf_token); await refresh(); })}>继续使用当前账号</Button>}
|
||||
{status.sms_enabled && <Form layout="vertical" onFinish={() => void run(async () => { const r = await onboardingRequest<{ csrf_token: string }>("/api/v1/web/auth/sms/verify", { sms_challenge_id: smsID, code }); setOnboardingCSRF(r.csrf_token); setCode(""); await refresh(); })}>
|
||||
<Form.Item label="手机号码(含国家区号)"><Input aria-label="手机号码(含国家区号)" value={phone} onChange={e => setPhone(e.target.value)} autoComplete="tel" inputMode="tel" maxLength={16} /></Form.Item>
|
||||
<Button block disabled={busy || countdown > 0 || !/^\+[1-9]\d{7,14}$/.test(phone)} onClick={() => void run(async () => { const body = { phone_e164: phone, purpose: "LOGIN" }; const r = await onboardingRequest<{ sms_challenge_id: string; resend_after_ms: number }>("/api/v1/web/auth/sms/send", body, { requestID: requestID("sms", body) }); setSMSID(r.sms_challenge_id); setCountdown(Math.ceil(r.resend_after_ms / 1000)); delete pendingRequest.current.sms; })}>{countdown ? `${countdown} 秒后可重发` : "获取验证码"}</Button>
|
||||
<Form.Item label="短信验证码"><Input aria-label="短信验证码" value={code} onChange={e => setCode(e.target.value)} autoComplete="one-time-code" inputMode="numeric" maxLength={6} /></Form.Item><Button htmlType="submit" type="primary" block loading={busy} disabled={!smsID || !/^\d{6}$/.test(code)}>验证并继续</Button>
|
||||
<p className="onboarding-note">首次验证后,将按当前设备所属组织的入驻规则创建普通账号。</p>
|
||||
</Form>}
|
||||
</Card> : pending ? <Card><Result status="info" title={status.claim_id ? "请按设备上的确认键" : "设备正在处理另一笔确认"} subTitle="在设备旁核对账号提示,再按键完成绑定。申请有效期最长 2 分钟。" />{status.claim_id && <Button block disabled={busy} onClick={() => void run(async () => { const body = { claim_id: status.claim_id, expected_binding_generation: status.state.binding_generation }; await onboardingRequest("/api/v1/web/claims/cancel", body, { requestID: requestID("cancel", body) }); await refresh(); })}>取消本次申请</Button>}</Card>
|
||||
: <Card title="这台设备给谁使用?"><Form layout="vertical" onFinish={() => void run(async () => { const body = { elder_id: selected, expected_binding_generation: status.state.binding_generation }; await onboardingRequest("/api/v1/web/claims", body, { requestID: requestID("claim", body) }); await refresh(); })}><Form.Item label="选择使用者"><Select aria-label="选择使用者" value={selected} onChange={setSelected} placeholder="请选择使用者" options={profiles.filter(p => p.elder_id).map(p => ({ value: p.elder_id, label: `${p.preferred_name} · ${p.household_name}` }))} /></Form.Item><Button type="primary" htmlType="submit" block disabled={!selected} loading={busy}>下一步,到设备确认</Button></Form><Button type="link" onClick={() => setAdding(v => !v)}>创建家庭或添加使用者</Button>
|
||||
{(adding || !profiles.length) && <Form layout="vertical" onFinish={(values: { household_id?: string; household_name?: string; preferred_name: string }) => void run(async () => { const result = await onboardingRequest<{ elder_id: string }>("/api/v1/web/profiles/create", values, { requestID: requestID("profile", values) }); const data = await onboardingRequest<{ items: ProfileOption[] }>("/api/v1/web/profiles"); setProfiles(data.items); setSelected(result.elder_id); setAdding(false); delete pendingRequest.current.profile; })}><Form.Item name="household_id" label="已有家庭"><Select allowClear placeholder="不选择则创建新家庭" options={Array.from(new Map(profiles.map(p => [p.household_id, p])).values()).map(p => ({ value: p.household_id, label: p.household_name }))} /></Form.Item><Form.Item name="household_name" label="新家庭名称"><Input placeholder="例如:我们的家" maxLength={200} /></Form.Item><Form.Item name="preferred_name" label="使用者称呼" rules={[{ required: true, message: "请填写称呼" }]}><Input placeholder="例如:奶奶" maxLength={120} /></Form.Item><Button htmlType="submit" loading={busy}>保存使用者</Button></Form>}
|
||||
</Card>}
|
||||
</> : <Card><Result status="info" title="请从设备打开绑定入口" subTitle="一次性入口失效后,可从同一设备重新获取;已完成的绑定不会丢失。" extra={<Button href="/onboarding/devices">查看已有设备</Button>} /></Card>}
|
||||
<footer className="onboarding-footer"><Space wrap><a href="/">返回家庭平台</a><span>网络中断后可刷新恢复进度</span>{(status?.authenticated || devices.length > 0) && <Button type="link" onClick={() => void run(async () => { if (auth.oidcUser) await auth.logout(); else { await logoutOnboarding(); window.location.assign("/"); } })}>退出登录</Button>}</Space></footer>
|
||||
</div></main>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Table, Tag } from "antd";
|
||||
import type { CurrentUser } from "../api/client";
|
||||
import { activationLabel, onboardingRequest, type OnboardingDevice } from "../api/onboarding";
|
||||
|
||||
type Account = CurrentUser["accounts"][number];
|
||||
type Action = "inventory" | "delivery" | "release" | "rotation" | "reconcile";
|
||||
export function OpsOnboardingWorkspace({ accessToken, accounts }: { accessToken: string; accounts: Account[] }) {
|
||||
const [tenant, setTenant] = useState(accounts[0]?.tenant_id ?? "");
|
||||
const [items, setItems] = useState<OnboardingDevice[]>([]);
|
||||
const [selected, setSelected] = useState<OnboardingDevice>();
|
||||
const [action, setAction] = useState<Action>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [delivery, setDelivery] = useState<string>();
|
||||
const [form] = Form.useForm();
|
||||
const request = useRef<{ body: string; id: string } | undefined>(undefined);
|
||||
const account = accounts.find(a => a.tenant_id === tenant);
|
||||
const canWrite = account?.platform_role === "ops_admin" || account?.platform_role === "ops_operator";
|
||||
const refresh = useCallback(async () => { if (!tenant) return; const result = await onboardingRequest<{ items: OnboardingDevice[] }>("/api/v1/ops/device-inventory", undefined, { method: "GET", token: accessToken, tenant }); setItems(result.items); }, [tenant, accessToken]);
|
||||
useEffect(() => { setError(""); void refresh().catch(e => setError(e.message)); }, [refresh]);
|
||||
function open(next: Action, device?: OnboardingDevice) {
|
||||
request.current = undefined; setSelected(device); form.resetFields();
|
||||
setAction(next);
|
||||
}
|
||||
async function submit(values: Record<string, unknown>) {
|
||||
if (!action) return; setLoading(true); setError("");
|
||||
const body = { ...values };
|
||||
if (action === "reconcile" && body.conclusion !== "OBSERVED_REGISTERED") { delete body.device_secret; delete body.rtc_app_id; }
|
||||
const paths: Record<Action, string> = { inventory: "/api/v1/ops/device-inventory", delivery: `/api/v1/ops/device-inventory/${selected?.platform_device_id}/credential-deliveries`, release: `/api/v1/ops/device-inventory/${selected?.platform_device_id}/release`, rotation: `/api/v1/ops/device-inventory/${selected?.platform_device_id}/credential-rotations`, reconcile: `/api/v1/ops/onboarding-activations/${selected?.activation_id}/reconcile` };
|
||||
const text = JSON.stringify(body); if (request.current?.body !== text) request.current = { body: text, id: crypto.randomUUID() };
|
||||
try {
|
||||
const result = await onboardingRequest<Record<string, unknown>>(paths[action], body, { token: accessToken, tenant, requestID: request.current.id, idempotencyKey: request.current.id });
|
||||
if (action === "delivery") setDelivery(JSON.stringify(result, null, 2));
|
||||
setAction(undefined); form.resetFields(); await refresh();
|
||||
} catch (e) { setError((e as Error).message); } finally { setLoading(false); }
|
||||
}
|
||||
const title = { inventory: "登记生产设备", delivery: "交付加密烧录包", release: "确认校验并放行", rotation: "准备新版本凭据", reconcile: "记录激活对账结论" };
|
||||
return <Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Card><Space wrap><Select aria-label="客户组织" value={tenant} onChange={setTenant} style={{ minWidth: 240 }} options={accounts.map(a => ({ value: a.tenant_id, label: a.tenant_name }))} /><Button onClick={() => void refresh().catch(e => setError(e.message))}>刷新</Button><Button type="primary" disabled={!canWrite} onClick={() => open("inventory")}>登记生产设备</Button></Space><p>生产登记、凭据交付和放行完成后,用户通过手机申请,再按设备按键确认绑定。结果未知的供应商请求会保留原设备身份,等待对账。</p></Card>
|
||||
{error && <Alert type="error" showIcon title={error} />}
|
||||
<Table<OnboardingDevice> rowKey="platform_device_id" dataSource={items} scroll={{ x: 1150 }} pagination={{ pageSize: 20 }} columns={[
|
||||
{ title: "设备编号", dataIndex: "device_id", width: 200 },
|
||||
{ title: "生产状态", width: 115, render: (_, d) => <Tag color={d.released ? "success" : "warning"}>{d.released ? "已放行" : "待交付/放行"}</Tag> },
|
||||
{ title: "凭据版本", dataIndex: "key_version", width: 100 },
|
||||
{ title: "绑定代数", dataIndex: "binding_generation", width: 100 },
|
||||
{ title: "服务状态", width: 190, render: (_, d) => <><Tag color={d.activation_state === "READY" ? "success" : "processing"}>{activationLabel(d.activation_state)}</Tag><div>{d.last_error_code}</div></> },
|
||||
{ title: "操作", width: 380, render: (_, d) => <Space wrap><Button size="small" disabled={!canWrite} onClick={() => open("delivery", d)}>交付</Button><Button size="small" disabled={!canWrite} onClick={() => open("release", d)}>放行</Button><Button size="small" disabled={!canWrite || !d.released} onClick={() => open("rotation", d)}>轮换凭据</Button><Button size="small" disabled={account?.platform_role !== "ops_admin" || !d.activation_id} onClick={() => open("reconcile", d)}>对账</Button></Space> }
|
||||
]} />
|
||||
<Modal title={action ? title[action] : ""} open={!!action} onCancel={() => { setAction(undefined); form.resetFields(); }} onOk={() => form.submit()} confirmLoading={loading} destroyOnHidden>
|
||||
{error && <Alert type="error" title={error} style={{ marginBottom: 16 }} />}
|
||||
<Form form={form} initialValues={{ enrollment_job_id: selected?.enrollment_job_id, expected_key_version: (action === "release" ? selected?.pending_key_version : selected?.key_version) || 1, attempt_id: selected?.current_attempt_id, expected_version: selected?.activation_version, conclusion: "OBSERVED_REGISTERED", hardware_model: "KP01" }} layout="vertical" onFinish={values => void submit(values)} preserve={false}>
|
||||
{action === "inventory" && <><Form.Item name="factory_mac" label="出厂 MAC(12 位大写十六进制)" rules={[{ required: true, pattern: /^[0-9A-F]{12}$/, message: "例如 84FCE6123456" }]}><Input maxLength={12} /></Form.Item><Form.Item name="hardware_model" label="型号" rules={[{ required: true }]}><Input maxLength={32} /></Form.Item><Form.Item name="hardware_revision" label="硬件版本" rules={[{ required: true }]}><Input maxLength={16} /></Form.Item><Form.Item name="batch_no" label="生产批次" rules={[{ required: true }]}><Input maxLength={120} /></Form.Item></>}
|
||||
{(action === "delivery" || action === "release") && <Form.Item name="enrollment_job_id" label="交付任务编号" rules={[{ required: true }]}><Input /></Form.Item>}
|
||||
{action === "delivery" && <><p>填写产线接收方 RSA 公钥。平台返回的烧录包仅能由对应私钥解密,普通设备列表不会显示密钥。</p><Form.Item name="receiver_public_key_pem" label="RSA 公钥(PEM / PKIX,2048–4096 位)" rules={[{ required: true }]}><Input.TextArea rows={7} spellCheck={false} /></Form.Item></>}
|
||||
{(action === "release" || action === "rotation") && <Form.Item name="expected_key_version" label="预期凭据版本" rules={[{ required: true }]}><InputNumber min={1} precision={0} /></Form.Item>}
|
||||
{action === "release" && <Form.Item name="evidence_ref" label="烧录回读校验记录" rules={[{ required: true }]}><Input maxLength={500} /></Form.Item>}
|
||||
{action === "rotation" && <><p>新版本交付并确认放行后切换凭据;准备任务期间当前版本继续有效。</p><Form.Item name="reason" label="轮换原因" rules={[{ required: true }]}><Input maxLength={200} /></Form.Item></>}
|
||||
{action === "reconcile" && <><Alert type="info" showIcon title="先核对供应商记录或设备执行证据,再提交结论。界面不会发起供应商查询。" style={{ marginBottom: 16 }} /><Form.Item name="expected_version" label="预期激活任务版本" rules={[{ required: true }]}><InputNumber min={1} precision={0} /></Form.Item><Form.Item name="conclusion" label="对账结论" rules={[{ required: true }]}><Select options={[{ value: "OBSERVED_REGISTERED", label: "已核实注册成功" }, { value: "CONFIRMED_NOT_REGISTERED", label: "已核实没有注册,可重新执行" }, { value: "DEVICE_ATTEMPT_STOPPED", label: "已核实首次通话停止,可重新许可" }]} /></Form.Item><Form.Item noStyle shouldUpdate>{({ getFieldValue }) => getFieldValue("conclusion") === "OBSERVED_REGISTERED" ? <><Form.Item name="device_secret" label="核验后的 DeviceSecret(可选,仅用于平台代管通话)" rules={[{ min: 16 }]}><Input.Password autoComplete="off" maxLength={4096} /></Form.Item><Form.Item name="rtc_app_id" label="RTC App ID"><Input maxLength={200} /></Form.Item></> : getFieldValue("conclusion") === "DEVICE_ATTEMPT_STOPPED" ? <Form.Item name="attempt_id" label="设备尝试编号" rules={[{ required: true }]}><Input /></Form.Item> : null}</Form.Item><Form.Item name="evidence_ref" label="对账证据引用" rules={[{ required: true }]}><Input maxLength={500} /></Form.Item><Form.Item name="reason" label="处理说明" rules={[{ required: true }]}><Input.TextArea maxLength={200} /></Form.Item></>}
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal title="加密烧录包" open={!!delivery} onCancel={() => setDelivery(undefined)} footer={<Button onClick={() => { if (!delivery) return; const url = URL.createObjectURL(new Blob([delivery], { type: "application/json" })); const a = document.createElement("a"); a.href = url; a.download = "device-enrollment-package.json"; a.click(); URL.revokeObjectURL(url); }}>下载加密包</Button>}><p>交付结果包含任务期限和接收方公钥加密的数据,请交由受控产线解密烧录并回读校验。</p><Input.TextArea value={delivery} readOnly rows={10} /></Modal>
|
||||
</Space>;
|
||||
}
|
||||
@@ -2108,6 +2108,15 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
|
||||
}) {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [timelineDayState, setTimelineDayState] = useState<Record<string, boolean>>({});
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const summaryRef = useRef<HTMLElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (detail?.id && window.matchMedia("(max-width: 1180px)").matches) {
|
||||
summaryRef.current?.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
summaryRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [detail?.id]);
|
||||
const currentSummary = summaries.find((item) => item.type === "conversation_summary");
|
||||
const currentTopic = summaries.find((item) => item.type === "open_topic");
|
||||
const summaryParts = currentSummary?.content.split("\n关键词:") ?? [];
|
||||
@@ -2116,8 +2125,9 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
|
||||
const timelineGroups = groupConversationsByDay(conversations);
|
||||
return <div className="conversation-layout memory-timeline-page">
|
||||
<div className="memory-timeline-grid">
|
||||
<div className="conversation-list">
|
||||
<div className="conversation-filter">
|
||||
<div className="conversation-list" ref={listRef} tabIndex={-1}>
|
||||
<div className={`conversation-filter${filtersOpen ? " conversation-filter--expanded" : ""}`}>
|
||||
<button type="button" className="conversation-mobile-filter-toggle button-secondary" aria-expanded={filtersOpen} onClick={() => setFiltersOpen((value) => !value)}>{filtersOpen ? "收起筛选" : "更多筛选"}</button>
|
||||
<div className="conversation-filter__fields">
|
||||
<label><span>长者</span><select disabled={loadingMore} value={selectedID ?? ""} onChange={(event) => onSelect(event.target.value)}>{subjects.map((subject) => <option value={subject.id} key={subject.id}>{subject.preferred_name}</option>)}</select></label>
|
||||
<label><span>通话状态</span><select disabled={loadingMore} value={status} onChange={(event) => onStatus(event.target.value)}><option value="">全部状态</option><option value="ACTIVE">通话中</option><option value="COMPLETED">已完成</option><option value="FAILED">失败</option><option value="EXPIRED">已过期</option><option value="PREPARING">准备中</option><option value="ROOM_ISSUED">房间已分配</option><option value="STARTING_AGENT">智能体启动中</option><option value="STOPPING">结束中</option></select></label>
|
||||
@@ -2130,7 +2140,8 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
|
||||
{error ? <div className="conversation-list__error" role="alert">{error}</div> : null}
|
||||
{loading && conversations.length === 0 ? <StatePanel title="正在读取聊天记录" detail="按时间倒序加载…" /> : conversations.length ? <div className="memory-timeline-scroll"><div className="memory-timeline-list">{timelineGroups.map((group, index) => { const expanded = timelineDayState[group.key] ?? index < 2; return <section className="timeline-day-group" key={group.key}><button type="button" className="timeline-day-group__header" aria-expanded={expanded} aria-controls={`timeline-day-${group.key}`} onClick={() => setTimelineDayState((current) => ({ ...current, [group.key]: !expanded }))}><span><strong>{group.label}</strong><small>{group.weekday}</small></span><span className="timeline-day-group__count">{group.items.length} 次聊天</span><span className={expanded ? "timeline-day-group__chevron is-expanded" : "timeline-day-group__chevron"} aria-hidden="true">⌄</span></button>{expanded ? <div className="timeline-day-group__items" id={`timeline-day-${group.key}`}>{group.items.map((item) => { const insight = insights[item.id]; return <button type="button" className={selectedConversationID === item.id ? "conversation-row active" : "conversation-row"} key={item.id} disabled={detailLoading && selectedConversationID === item.id} onClick={() => void onOpen(item.id)}><span className="memory-timeline-dot" /><span className={`conversation-row__mood conversation-row__mood--${insight?.mood ?? "steady"}`}><SmileOutlined /></span><div><strong>{formatConversationTime(item.created_at)} · {formatDuration(item.duration_ms)}</strong><span>{insight?.summary || conversationFailureLabel(item.failure_code) || "摘要整理中"}</span>{insight?.topics.length ? <small>{insight.topics.join(" · ")}</small> : null}</div><span className={`tag tag--${item.status.toLowerCase()}`}>{conversationStatusLabel(item.status)}</span></button>; })}</div> : null}</section>; })}</div>{hasMore ? <div className="conversation-list__more"><button type="button" className="button-secondary" disabled={loading || loadingMore} onClick={() => void onLoadMore()}>{loadingMore ? "正在加载…" : "加载更多记录"}</button></div> : null}</div> : <p className="empty-copy padded">暂无聊天记录。</p>}
|
||||
</div>
|
||||
<section className="memory-summary-panel" aria-label="智能总结">
|
||||
<section className="memory-summary-panel" aria-label="智能总结" ref={summaryRef} tabIndex={-1}>
|
||||
<button type="button" className="conversation-return-list button-secondary" onClick={() => { listRef.current?.scrollIntoView({ block: "start", behavior: "instant" }); listRef.current?.focus({ preventScroll: true }); }}>返回聊天列表</button>
|
||||
{detailLoading ? <StatePanel title="正在生成回忆卡片" detail="读取聊天、摘要和故事候选…" /> : detail ? <>
|
||||
<header><div><p className="eyebrow">AI MEMORY NOTE</p><h3>这次聊天,长者想起了什么</h3></div><span className="memory-summary-panel__mood"><SmileOutlined /> {insights[detail.id]?.moodLabel ?? "平稳"}</span></header>
|
||||
<div className="memory-summary-panel__meta"><span>{formatDate(detail.created_at)}</span><span>{formatDuration(detail.duration_ms)}</span><span>来源 {currentSummary?.source_turn_ids.length ?? turns.length} 条话轮</span></div>
|
||||
|
||||
Reference in New Issue
Block a user