feat: finalize operations model workspace

This commit is contained in:
Codex Local
2026-08-31 11:29:56 +08:00
parent 78e0a19acb
commit a8cdec98d1
15 changed files with 406 additions and 63 deletions
+1 -1
View File
@@ -583,7 +583,7 @@ input[type="checkbox"], input[type="radio"], input[type="range"] { accent-color:
.workspace__header h2 { font-size: 25px !important; }
.workspace__action-message,.workspace__inline-error,.workspace__readonly-note { margin: 0 0 14px !important; padding: 12px 14px !important; border-radius: 10px !important; }
.customer-layout button:disabled,
.customer-layout [role="button"][aria-disabled="true"] { cursor: wait !important; opacity: .52 !important; transform: none !important; }
.customer-layout [role="button"][aria-disabled="true"] { cursor: not-allowed !important; opacity: .52 !important; transform: none !important; }
/* Shared objects use one clear surface; avoid nested full-page cards. */
.customer-page .panel-section,
+14 -3
View File
@@ -1,4 +1,4 @@
import { StrictMode } from "react";
import { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
@@ -7,7 +7,18 @@ import { MaintenancePortal } from "./pages/MaintenancePortal";
import "./styles.css";
import "./cal-theme.css";
const Portal = window.location.pathname.startsWith("/maintenance") ? MaintenancePortal : CustomerPortal;
function PortalRouter() {
const [pathname, setPathname] = useState(() => window.location.pathname);
useEffect(() => {
const syncPathname = () => setPathname(window.location.pathname);
window.addEventListener("popstate", syncPathname);
return () => window.removeEventListener("popstate", syncPathname);
}, []);
const Portal = pathname.startsWith("/maintenance") ? MaintenancePortal : CustomerPortal;
return <Portal />;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
@@ -67,7 +78,7 @@ createRoot(document.getElementById("root")!).render(
}
}}
>
<Portal />
<PortalRouter />
</ConfigProvider>
</StrictMode>
);
+1 -6
View File
@@ -42,7 +42,6 @@ import {
SafetyCertificateOutlined,
SearchOutlined,
SettingOutlined,
RobotOutlined,
TeamOutlined,
UserOutlined
} from "@ant-design/icons";
@@ -62,7 +61,6 @@ const sectionMeta: Record<PortalSection, { title: string; description: string; b
privacy: { title: "数据与授权", description: "按项目查看授权,并申请访问、更正、撤回、删除或私有导出。", breadcrumb: "隐私与授权 / 数据与授权" },
billing: { title: "服务用量", description: "围绕长者聊天查看时长、活跃日和最近通话。", breadcrumb: "服务用量" },
messages: { title: "提醒与待办", description: "查看服务端记录的陪伴动态,并处理故事、设备和隐私事项。", breadcrumb: "提醒与待办" },
models: { title: "大模型管理", description: "配置、测试并激活用于聊天总结、关键词和故事候选生成的大模型。", breadcrumb: "设置与权限 / 大模型管理" },
profile: { title: "设置与权限", description: "查看登录身份、家庭角色和全部权限范围。", breadcrumb: "设置与权限" }
};
@@ -99,7 +97,6 @@ export function CustomerPortal() {
]
},
{ key: "billing", icon: <CreditCardOutlined />, label: "服务用量" },
{ key: "models", icon: <RobotOutlined />, label: "大模型管理" },
{ key: "profile", icon: <SettingOutlined />, label: "设置与权限" }
], []);
@@ -123,7 +120,6 @@ export function CustomerPortal() {
[["分享", "share"], "sharing"],
[["费用", "额度", "账单", "cost"], "billing"],
[["消息", "通知", "提醒"], "messages"],
[["大模型", "模型", "豆包", "总结"], "models"],
[["长者", "老人", "档案", "subject"], "subjects"],
[["家庭", "成员", "邀请", "member"], "members"]
];
@@ -271,11 +267,10 @@ const permissionRows = [
["privacy.manage", "处理隐私请求", true, false, false, false],
["recording.play", "播放聊天录音", false, false, false, false],
["recording.download", "下载聊天录音", false, false, false, false],
["model.manage", "管理总结大模型", true, true, false, false],
] as const;
function PermissionMatrix() {
return <Card className="permission-matrix-card" title="全部权限说明" extra={<Tag>15 项</Tag>}>
return <Card className="permission-matrix-card" title="全部权限说明" extra={<Tag>{permissionRows.length} 项</Tag>}>
<p className="permission-matrix-note">家庭角色决定一般操作权限;播放和下载录音属于敏感权限,必须由所有者或管理员按长者、用途和有效期单独授权。</p>
<div className="permission-matrix-wrap"><table className="permission-matrix"><thead><tr><th>权限</th><th>所有者</th><th>管理员</th><th>编辑者</th><th>查看者</th></tr></thead><tbody>{permissionRows.map(([code, label, owner, admin, editor, viewer]) => <tr key={code}><th><strong>{label}</strong><small>{code}</small></th>{[owner, admin, editor, viewer].map((enabled, index) => <td key={index} className={enabled ? "is-enabled" : ""}>{enabled ? <CheckOutlined aria-label="包含" /> : <MinusOutlined aria-label="不包含" />}</td>)}</tr>)}</tbody></table></div>
</Card>;
+24 -2
View File
@@ -29,6 +29,7 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
ReloadOutlined,
RobotOutlined,
SearchOutlined,
SettingOutlined,
ToolOutlined,
@@ -42,8 +43,9 @@ import { OpsBillingWorkspace } from "./OpsBillingWorkspace";
import { OpsConfigWorkspace } from "./OpsConfigWorkspace";
import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace";
import { OpsDeviceWorkspace } from "./OpsDeviceWorkspace";
import { OpsContentModelWorkspace } from "./OpsContentModelWorkspace";
type MaintenanceArea = "overview" | "devices" | "config" | "quality" | "billing" | "alerts" | "diagnostics";
type MaintenanceArea = "overview" | "devices" | "config" | "models" | "quality" | "billing" | "alerts" | "diagnostics";
type HealthState = {
loading: boolean;
@@ -59,6 +61,7 @@ const areaMeta: Record<MaintenanceArea, { title: string; description: string; ic
overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> },
devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> },
config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: <SettingOutlined /> },
models: { title: "大模型管理", description: "按客户组织配置、测试并激活聊天总结、关键词和故事候选生成模型。", icon: <RobotOutlined /> },
quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: <BarChartOutlined /> },
billing: { title: "用量、费用与额度", description: "查看整体通话时长并按家庭、设备筛选,同时维护核价与硬额度策略。", icon: <DollarOutlined /> },
alerts: { title: "告警处置", description: "维护告警路由、通知测试、静默窗口和处置时间线。", icon: <AlertOutlined /> },
@@ -69,6 +72,7 @@ export function MaintenancePortal() {
const auth = useAuth();
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720);
const [area, setArea] = useState<MaintenanceArea>(() => areaFromURL());
const [searchValue, setSearchValue] = useState("");
const [health, setHealth] = useState<HealthState>({ loading: true });
const refreshHealth = useCallback(async (signal?: AbortSignal) => {
@@ -120,11 +124,28 @@ export function MaintenancePortal() {
window.history.pushState({ area: next }, "", `${url.pathname}${url.search}${url.hash}`);
}
function search() {
const value = searchValue.trim().toLowerCase();
if (!value) return;
const matches: Array<[string[], MaintenanceArea]> = [
[["总览", "健康", "版本", "health"], "overview"],
[["设备管理", "资产", "密钥", "device management"], "devices"],
[["配置", "灰度", "模型路由", "config"], "config"],
[["大模型", "模型", "豆包", "ollama", "model"], "models"],
[["质量", "审核", "反馈", "quality"], "quality"],
[["费用", "额度", "账单", "用量", "billing", "cost"], "billing"],
[["告警", "静默", "路由", "alert"], "alerts"]
];
const target = matches.find(([keywords]) => keywords.some((keyword) => value.includes(keyword)))?.[1] ?? "diagnostics";
navigate(target);
}
const menuItems: MenuProps["items"] = [
{ type: "group", label: "平台维护", children: [
{ key: "overview", icon: <DashboardOutlined />, label: "维护总览" },
{ key: "devices", icon: <DatabaseOutlined />, label: "设备管理" },
{ key: "config", icon: <SettingOutlined />, label: "运行配置" },
{ key: "models", icon: <RobotOutlined />, label: "大模型管理" },
{ key: "quality", icon: <BarChartOutlined />, label: "内容质量" },
{ key: "billing", icon: <DollarOutlined />, label: "用量与费用" },
{ key: "alerts", icon: <AlertOutlined />, label: "告警处置" },
@@ -147,7 +168,7 @@ export function MaintenancePortal() {
{(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" />
<Input.Search value={searchValue} onChange={(event) => setSearchValue(event.target.value)} onSearch={search} 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>}
@@ -176,6 +197,7 @@ export function MaintenancePortal() {
{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 === "models" ? <OpsContentModelWorkspace 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}
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useState } from "react";
import { Alert, Button, Card, Select, Space } from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import { platformApi, type ContentModel, type CreateContentModelRequest, type CurrentUser } from "../api/client";
import { ContentModelPanel } from "./SaaSWorkspace";
export function OpsContentModelWorkspace({ accessToken, accounts }: {
accessToken: string;
accounts: CurrentUser["accounts"];
}) {
const [tenantID, setTenantID] = useState(accounts[0]?.tenant_id ?? "");
const [models, setModels] = useState<ContentModel[]>([]);
const [loading, setLoading] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [error, setError] = useState<string>();
const [message, setMessage] = useState<string>();
const account = accounts.find((item) => item.tenant_id === tenantID);
const canManage = Boolean(account && ["ops_operator", "ops_admin"].includes(account.platform_role));
useEffect(() => {
if (!accounts.some((item) => item.tenant_id === tenantID)) setTenantID(accounts[0]?.tenant_id ?? "");
}, [accounts, tenantID]);
const loadModels = useCallback(async (signal?: AbortSignal) => {
if (!tenantID) { setModels([]); return; }
setLoading(true);
setError(undefined);
try {
setModels((await platformApi.contentModels(tenantID, accessToken, signal)).data.items);
} catch (cause) {
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(errorText(cause));
} finally {
if (!signal?.aborted) setLoading(false);
}
}, [accessToken, tenantID]);
useEffect(() => {
const controller = new AbortController();
void loadModels(controller.signal);
return () => controller.abort();
}, [loadModels]);
async function runAction(successMessage: string, action: () => Promise<void>) {
setActionBusy(true);
setError(undefined);
setMessage(undefined);
try {
await action();
await loadModels();
setMessage(successMessage);
return true;
} catch (cause) {
setError(errorText(cause));
return false;
} finally {
setActionBusy(false);
}
}
async function createModel(input: CreateContentModelRequest) {
if (!tenantID || !canManage) return false;
return runAction("大模型配置已安全保存,请完成连通性测试后再激活。", async () => {
await platformApi.createContentModel(tenantID, input, accessToken);
});
}
async function testModel(item: ContentModel) {
if (!tenantID || !canManage) return;
await runAction("连通性测试已完成。", async () => {
await platformApi.testContentModel(tenantID, item.id, accessToken);
});
}
async function activateModel(item: ContentModel) {
if (!tenantID || !canManage) return;
await runAction(`“${item.display_name}”已激活,下一次总结任务会立即使用该模型。`, async () => {
await platformApi.activateContentModel(tenantID, item, accessToken);
});
}
async function revokeModel(item: ContentModel) {
if (!tenantID || !canManage) return;
await runAction("未激活模型已移除,密钥材料已清除。", async () => {
await platformApi.revokeContentModel(tenantID, item, accessToken);
});
}
return <div className="ops-content-model-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((item) => ({ value: item.tenant_id, label: item.tenant_name }))} />
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadModels()}>刷新模型配置</Button>
</Space>
</Card>
{!canManage ? <Alert type="info" showIcon message="当前维护角色为只读" description="维护查看员和维护审批员可以查看配置,但不能新增、测试、激活或移除模型。" /> : null}
{message ? <Alert type="success" showIcon closable message={message} onClose={() => setMessage(undefined)} /> : null}
<ContentModelPanel models={models} loading={loading} error={error} canManage={canManage} actionBusy={actionBusy}
onCreate={createModel} onTest={testModel} onActivate={activateModel} onRevoke={revokeModel} />
</div>;
}
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : "大模型配置操作失败";
}
+1 -1
View File
@@ -53,7 +53,7 @@ export function OpsDiagnosticsWorkspace({ accessToken, accounts }: { accessToken
async function search(event?:FormEvent){event?.preventDefault();if(!value.trim())return;setDiagnosticControlDraft(undefined);setDiagnosticBundleDraft(undefined);setLoading(true);setError(undefined);setMessage(undefined);try{const result=await platformApi.searchOpsDiagnostics(tenantID,field,value.trim(),accessToken);setItems(result.data.items)}catch(cause){setError(errorText(cause))}finally{setLoading(false)}}
async function loadExternalDependencies(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsExternalDependencies(tenantID,accessToken,signal);setExternalDependencies(result.data.items)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}}
async function loadPlatformHealth(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsPlatformHealth(tenantID,accessToken,signal);setPlatformHealth(result.data)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}}
async function loadPlatformHealth(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsPlatformHealth(tenantID,accessToken,signal);setPlatformHealth(result.data)}catch(cause){if(cause instanceof APIRequestError&&cause.status===404){setPlatformHealth(undefined);return}if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}}
function openDiagnosticControl(kind:DiagnosticControlKind,item:OpsDiagnosticRecord){setDiagnosticControlDraft({kind,item,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)}
async function executeDiagnosticControl(event:FormEvent){
event.preventDefault();const draft=diagnosticControlDraft;if(!draft||!draft.reason.trim()||(diagnosticControlNeedsAcknowledgement(draft.kind)&&!draft.acknowledged))return;
+34 -31
View File
@@ -77,7 +77,7 @@ import {
import { contentModelProviderLabel, contentModelProviderPreset, contentModelProviderPresets } from "../contentModelProviders";
import { FamilyUsageOverview } from "../components/UsageOverview";
export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages" | "models";
export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages";
type Tab = CustomerSection | "firmware";
type LoadState = "idle" | "loading" | "ready" | "error";
type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error";
@@ -199,8 +199,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const [turns, setTurns] = useState<ConversationTurn[]>([]);
const [contentSummaries, setContentSummaries] = useState<ConversationContentSummary[]>([]);
const [contentModels, setContentModels] = useState<ContentModel[]>([]);
const [modelLoading, setModelLoading] = useState(false);
const [modelError, setModelError] = useState<string>();
const [conversationInsights, setConversationInsights] = useState<Record<string, ConversationInsight>>({});
const [conversationMedia, setConversationMedia] = useState<ConversationMedia>();
const [mediaState, setMediaState] = useState<MediaLoadState>("idle");
@@ -582,7 +580,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const canManageFirmware = enableFirmware && (selectedHousehold?.capabilities.includes("firmware.manage") ?? false);
const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
const canManageSharing = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
const canManageModels = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
const policyVersion = accounts.find((account) => account.tenant_id === selectedHousehold?.tenant_id)?.policy_version ?? "";
const consentNotice = `我已阅读隐私政策${policyVersion || "当前"}版本,并同意对后续新通话启用“${consentLabel(newConsentType)}”。${consentImpactLabel(newConsentType)}我知道该授权可以单独撤回。`;
const selectedConsentAlreadyEffective = consents.some((item) => item.type === newConsentType && item.effective);
@@ -591,30 +588,12 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const loadContentModels = useCallback(async (signal?: AbortSignal) => {
if (!selectedHousehold?.tenant_id) { setContentModels([]); return; }
setModelLoading(true); setModelError(undefined);
try { setContentModels((await platformApi.contentModels(selectedHousehold.tenant_id, accessToken, signal)).data.items); }
catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) setModelError(errorMessage(cause)); }
finally { if (!signal?.aborted) setModelLoading(false); }
catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) setContentModels([]); }
}, [accessToken, selectedHousehold?.tenant_id]);
useEffect(() => { const controller = new AbortController(); void loadContentModels(controller.signal); return () => controller.abort(); }, [loadContentModels, refreshRevision]);
async function createContentModel(input: CreateContentModelRequest) {
if (!selectedHousehold?.tenant_id) return false;
return runAction("大模型配置已安全保存,请完成连通性测试后激活。", async () => { await platformApi.createContentModel(selectedHousehold.tenant_id, input, accessToken); await loadContentModels(); });
}
async function testContentModel(item: ContentModel) {
if (!selectedHousehold?.tenant_id) return;
await runAction("连通性测试已完成。", async () => { await platformApi.testContentModel(selectedHousehold.tenant_id, item.id, accessToken); await loadContentModels(); });
}
async function activateContentModel(item: ContentModel) {
if (!selectedHousehold?.tenant_id) return;
await runAction(`“${item.display_name}”已激活,下一次总结任务会立即使用该模型。`, async () => { await platformApi.activateContentModel(selectedHousehold.tenant_id, item, accessToken); await loadContentModels(); });
}
async function revokeContentModel(item: ContentModel) {
if (!selectedHousehold?.tenant_id) return;
await runAction("未激活模型已移除,密钥材料已清除。", async () => { await platformApi.revokeContentModel(selectedHousehold.tenant_id, item, accessToken); await loadContentModels(); });
}
async function regenerateSummary() {
if (!selectedConversationID) return;
await runAction("已使用当前激活模型排队重新总结;结果生成后会自动刷新。", async () => {
@@ -1509,7 +1488,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
stories={stories} onOpenStories={() => selectSection("stories")}
onRevoke={revokeRecordingAccess} onAccess={accessRecording} />
) : null}
{tab === "models" ? <ContentModelPanel models={contentModels} loading={modelLoading} error={modelError} canManage={canManageModels} actionBusy={actionBusy} onCreate={createContentModel} onTest={testContentModel} onActivate={activateContentModel} onRevoke={revokeContentModel} /> : null}
{tab === "stories" ? <StoryPanel selectedID={selectedSubjectID}
stories={stories} memories={memories} selected={selectedStory} openRequestID={storyReviewRequestID} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory}
onOpen={openStory} onOpenConversation={openStoryConversation} onRevise={reviseStory} onConfirm={confirmStory} onReject={rejectStory} onUpdateFact={updateFactCandidate} onUpdateMemory={updateMemory} onDeleteMemory={deleteMemory} /> : null}
@@ -1636,7 +1614,7 @@ function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequ
}
function customerSectionTitle(section: Tab) {
return ({ home: "陪伴周记", members: "家庭管理", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "数据与授权", billing: "服务用量", messages: "提醒与待办", models: "大模型管理" } as Record<Tab, string>)[section];
return ({ home: "陪伴周记", members: "家庭管理", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "数据与授权", billing: "服务用量", messages: "提醒与待办" } as Record<Tab, string>)[section];
}
function customerSectionDescription(section: Tab) {
@@ -1651,8 +1629,7 @@ function customerSectionDescription(section: Tab) {
sharing: "按资源和权限范围向指定家庭成员分享内容。",
privacy: "提交访问、导出或删除申请并跟踪处理进度。",
billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。",
messages: "集中查看服务端记录的陪伴动态,并处理设备、故事和隐私请求待办。",
models: "配置、测试并激活用于聊天摘要、关键词和故事候选生成的大模型。"
messages: "集中查看服务端记录的陪伴动态,并处理设备、故事和隐私请求待办。"
} as Record<Tab, string>)[section];
}
@@ -2130,11 +2107,13 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
stories: Story[]; onOpenStories: () => void;
}) {
const [detailOpen, setDetailOpen] = useState(false);
const [timelineDayState, setTimelineDayState] = useState<Record<string, boolean>>({});
const currentSummary = summaries.find((item) => item.type === "conversation_summary");
const currentTopic = summaries.find((item) => item.type === "open_topic");
const summaryParts = currentSummary?.content.split("\n关键词:") ?? [];
const keywords = summaryParts[1]?.split("、").filter(Boolean) ?? [];
const storyCandidate = detail ? stories.find((item) => item.conversation_id === detail.id) : undefined;
const timelineGroups = groupConversationsByDay(conversations);
return <div className="conversation-layout memory-timeline-page">
<div className="memory-timeline-grid">
<div className="conversation-list">
@@ -2146,11 +2125,10 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
<label><span>开始日期</span><input disabled={loadingMore} type="date" value={from} max={to || undefined} onChange={(event) => onFrom(event.target.value)} /></label>
<label><span>结束日期</span><input disabled={loadingMore} type="date" value={to} min={from || undefined} onChange={(event) => onTo(event.target.value)} /></label>
</div>
<div className="conversation-filter__summary"><span>已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""}</span>{status || engineMode || from || to ? <button type="button" className="button-small button-secondary" onClick={onResetFilters}>清除筛选</button> : null}</div>
<div className="conversation-filter__summary"><span>已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""} · 最近 2 个记录日默认展开</span>{status || engineMode || from || to ? <button type="button" className="button-small button-secondary" onClick={onResetFilters}>清除筛选</button> : null}</div>
</div>
{error ? <div className="conversation-list__error" role="alert">{error}</div> : null}
{loading && conversations.length === 0 ? <StatePanel title="正在读取聊天记录" detail="按时间倒序加载…" /> : conversations.length ? <div className="memory-timeline-list">{conversations.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>{formatDate(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> : <p className="empty-copy padded">暂无聊天记录。</p>}
{hasMore ? <div className="conversation-list__more"><button type="button" className="button-secondary" disabled={loading || loadingMore} onClick={() => void onLoadMore()}>{loadingMore ? "正在加载…" : "加载更多记录"}</button></div> : null}
{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="智能总结">
{detailLoading ? <StatePanel title="正在生成回忆卡片" detail="读取聊天、摘要和故事候选…" /> : detail ? <>
@@ -2180,7 +2158,7 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
</div>;
}
function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCreate, onTest, onActivate, onRevoke }: {
export function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCreate, onTest, onActivate, onRevoke }: {
models: ContentModel[]; loading: boolean; error?: string; canManage: boolean; actionBusy: boolean;
onCreate: (input: CreateContentModelRequest) => Promise<boolean>; onTest: (item: ContentModel) => Promise<void>;
onActivate: (item: ContentModel) => Promise<void>; onRevoke: (item: ContentModel) => Promise<void>;
@@ -2720,6 +2698,31 @@ function subjectProfileChangeSummary(subject: Subject, input: UpdateSubjectReque
if (JSON.stringify(nextAvoidedTopics) !== JSON.stringify(currentAvoidedTopics)) changes.push(`避免主动提及“${contextItemsLabel(currentAvoidedTopics)}”→“${contextItemsLabel(nextAvoidedTopics)}”`);
return changes.length ? changes : ["无有效变化"];
}
function groupConversationsByDay(conversations: ConversationSummary[]) {
const groups = new Map<string, { key: string; label: string; weekday: string; items: ConversationSummary[] }>();
const today = localDateKey(new Date());
const yesterdayDate = new Date();
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterday = localDateKey(yesterdayDate);
for (const conversation of conversations) {
const date = new Date(conversation.created_at);
const key = localDateKey(date);
let group = groups.get(key);
if (!group) {
group = {
key,
label: key === today ? "今天" : key === yesterday ? "昨天" : new Intl.DateTimeFormat("zh-CN", { month: "long", day: "numeric" }).format(date),
weekday: new Intl.DateTimeFormat("zh-CN", { weekday: "short" }).format(date),
items: []
};
groups.set(key, group);
}
group.items.push(conversation);
}
return [...groups.values()];
}
function formatConversationTime(value: string) { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value)); }
function formatDate(value: string) { return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); }
function statusLabel(value: string) { return ({ active: "启用", suspended: "暂停", deleting: "删除处理中" } as Record<string, string>)[value] ?? value; }
function factTypeLabel(value: string) { return ({ family_note: "家庭信息", preference: "个人偏好", life_event: "人生事件", health_context: "健康背景" } as Record<string, string>)[value] ?? value; }
+45 -5
View File
@@ -30,7 +30,7 @@ button {
}
button:hover { transform: translateY(-1px); }
button:disabled { cursor: wait; opacity: 0.65; }
button:disabled { cursor: not-allowed; opacity: 0.65; }
.button-secondary { color: #244356; background: #e5ece9; }
main {
@@ -45,12 +45,22 @@ main {
.memory-timeline-page { display: block !important; min-height: 0 !important; border: 0 !important; overflow: visible !important; background: transparent !important; }
.memory-timeline-grid { display: grid; grid-template-columns: minmax(280px, .78fr) minmax(420px, 1.28fr) minmax(220px, .62fr); gap: 16px; align-items: start; }
.memory-timeline-grid > * { min-width: 0; border: 1px solid #eadeda; border-radius: 18px; background: #fffdfb; box-shadow: 0 10px 30px rgba(91, 64, 55, .055); }
.memory-timeline-grid .conversation-list { overflow: hidden; }
.memory-timeline-grid .conversation-list { display: flex; max-height: min(720px, calc(100dvh - 210px)); overflow: hidden; flex-direction: column; }
.memory-timeline-grid .conversation-filter { border-bottom: 1px solid #eee4e1; background: #faf6f2; }
.memory-timeline-grid .conversation-filter__fields { grid-template-columns: 1fr 1fr; }
.memory-timeline-grid .conversation-filter__fields label:nth-child(n+3) { display: none; }
.memory-timeline-list { position: relative; padding: 8px 0 12px; }
.memory-timeline-list::before { position: absolute; top: 15px; bottom: 22px; left: 25px; width: 2px; content: ""; background: #dfcbc4; }
.memory-timeline-scroll { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.memory-timeline-list { padding: 8px 0 12px; }
.timeline-day-group + .timeline-day-group { border-top: 1px solid #eee5e1; }
.timeline-day-group__header { display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 8px; width: 100%; padding: 11px 14px; border: 0; border-radius: 0; color: #66514a; background: #fffaf7; box-shadow: none; text-align: left; }
.timeline-day-group__header:hover { color: #793b2c; background: #f8eeea; transform: none; box-shadow: none; }
.timeline-day-group__header > span:first-child { display: flex; align-items: baseline; gap: 7px; min-width: 0; }
.timeline-day-group__header strong { color: #553f39; font-size: 13px; }
.timeline-day-group__header small, .timeline-day-group__count { color: #9a8179; font-size: 11px; font-weight: 500; }
.timeline-day-group__chevron { color: #a27164; font-size: 18px; line-height: 1; transform: rotate(-90deg); transition: transform .18s ease; }
.timeline-day-group__chevron.is-expanded { transform: rotate(0deg); }
.timeline-day-group__items { position: relative; padding: 2px 0 8px; }
.timeline-day-group__items::before { position: absolute; top: 9px; bottom: 17px; left: 25px; width: 2px; content: ""; background: #dfcbc4; }
.memory-timeline-list .conversation-row { position: relative; grid-template-columns: 16px 36px 1fr; margin: 0 8px; width: calc(100% - 16px); border: 0; border-radius: 13px; background: transparent; }
.memory-timeline-list .conversation-row.active { background: #f7ebe6; box-shadow: inset 3px 0 #a73920; }
.memory-timeline-list .conversation-row > .tag { grid-column: 3; justify-self: start; }
@@ -3506,7 +3516,7 @@ textarea:focus,
.customer-layout button:not(.ant-btn) { border-radius: 8px !important; }
.customer-layout button:disabled,
.customer-layout [role="button"][aria-disabled="true"] { cursor: wait !important; opacity: .5; transform: none !important; }
.customer-layout [role="button"][aria-disabled="true"] { cursor: not-allowed !important; opacity: .5; transform: none !important; }
.subject-avatar-upload { position: relative; display: grid; grid-row: span 2; place-items: center; align-content: center; gap: 7px; min-height: 132px; overflow: hidden; border: 1px dashed #c9c0b5; border-radius: 12px; color: var(--journal-blue); background: #fbf8f3; cursor: pointer; }
.subject-avatar-upload input,.subject-avatar-editor input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.subject-avatar-upload > .anticon { font-size: 27px; }
@@ -3582,3 +3592,33 @@ textarea:focus,
.customer-page--conversations .memory-timeline-grid { grid-template-columns: 1fr; }
.customer-page--conversations .memory-model-card { grid-column: auto; }
}
/* Keep family-facing interactive cards in the warm, calm customer palette. */
.customer-page .timeline-day-group__header:not(.ant-btn) { border: 0 !important; border-radius: 0 !important; color: #66514a !important; background: #fffaf7 !important; box-shadow: none !important; }
.customer-page .timeline-day-group__header:not(.ant-btn):hover { color: #793b2c !important; background: #f8eeea !important; }
:is(.customer-page, .maintenance-workspace) .model-provider-group button:not(.ant-btn) { border: 1px solid #e6d9d4 !important; color: #594a45 !important; background: #fff !important; box-shadow: none !important; }
:is(.customer-page, .maintenance-workspace) .model-provider-group button:not(.ant-btn):hover { border-color: #cfa79c !important; color: #6e3f34 !important; background: #fff8f5 !important; }
:is(.customer-page, .maintenance-workspace) .model-provider-group button.is-selected:not(.ant-btn) { border-color: #78919a !important; color: #344e5a !important; background: #edf4f6 !important; box-shadow: inset 0 0 0 1px #78919a !important; }
.ops-content-model-workspace { display: grid; gap: 16px; }
.customer-page .reminder-todos { display: grid; overflow: hidden; margin: 0 18px 18px; border: 1px solid #eee2de; border-radius: 12px; background: #fff; }
.customer-page .reminder-todo:not(.ant-btn) { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 70px; padding: 13px 16px !important; border: 0 !important; border-bottom: 1px solid #f0e7e4 !important; border-radius: 0 !important; color: #554641 !important; background: #fff !important; box-shadow: none !important; text-align: left; }
.customer-page .reminder-todo:last-child { border-bottom: 0 !important; }
.customer-page .reminder-todo:hover:not(.ant-btn) { color: #554641 !important; background: #fff8f5 !important; }
.customer-page .reminder-todo > span { width: 7px; height: 7px; border-radius: 50%; background: #6f91a1; }
.customer-page .reminder-todo--warm > span { background: #d27755; }
.customer-page .reminder-todo strong, .customer-page .reminder-todo p { margin: 0; }
.customer-page .reminder-todo strong { color: #4e3e39; font-size: 13px; }
.customer-page .reminder-todo p { margin-top: 4px; color: #8b7770; font-size: 11px; font-weight: 400; line-height: 1.45; }
.customer-page .reminder-todo small { color: #8e4939; font-size: 11px; white-space: nowrap; }
.customer-page .reminder-history__summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; margin: 0 18px 18px; border: 1px solid #eee2de; border-radius: 12px; background: #fff; }
.customer-page .reminder-history__summary > button:not(.ant-btn), .customer-page .reminder-history__summary > div { display: grid; gap: 5px; min-height: 84px; padding: 17px 18px !important; border: 0 !important; border-right: 1px solid #f0e7e4 !important; border-radius: 0 !important; color: #554641 !important; background: #fff !important; box-shadow: none !important; text-align: left; }
.customer-page .reminder-history__summary > *:last-child { border-right: 0 !important; }
.customer-page .reminder-history__summary > button:hover:not(.ant-btn) { color: #554641 !important; background: #fff8f5 !important; }
.customer-page .reminder-history__summary strong { color: #65473e; font-size: 23px; }
.customer-page .reminder-history__summary span { color: #8b7770; font-size: 11px; font-weight: 500; }
@media (max-width: 760px) {
.memory-timeline-grid .conversation-list { max-height: min(640px, 68dvh); }
.customer-page .reminder-history__summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.customer-page .reminder-history__summary > *:nth-child(2) { border-right: 0 !important; }
.customer-page .reminder-history__summary > *:nth-child(-n+2) { border-bottom: 1px solid #f0e7e4 !important; }
}