feat: finalize operations model workspace
This commit is contained in:
@@ -334,11 +334,10 @@ func (s *Service) resolveActor(ctx context.Context, principal identity.Principal
|
|||||||
var result actor
|
var result actor
|
||||||
err := s.registry.pool.QueryRow(ctx, `
|
err := s.registry.pool.QueryRow(ctx, `
|
||||||
SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,
|
SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,
|
||||||
COALESCE(bool_or(hm.role IN ('owner','admin') AND hm.status='active'),false)
|
ua.account_kind='operator' AND ua.platform_role IN ('ops_operator','ops_admin')
|
||||||
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active'
|
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active'
|
||||||
LEFT JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=t.id
|
|
||||||
WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
|
WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
|
||||||
GROUP BY t.id,t.public_id,ua.id,ua.public_id`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.canManage)
|
`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.canManage)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return actor{}, ErrAccessDenied
|
return actor{}, ErrAccessDenied
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package contentmodel
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -38,7 +39,14 @@ func TestServiceTestsAndActivatesCompatibleModel(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||||
|
customerPrincipal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||||
|
if _, err := service.Create(ctx, customerPrincipal, CreateInput{
|
||||||
|
TenantID: "10000000-0000-0000-0000-000000000010", DisplayName: "家庭端不应创建", Provider: "openai_compatible",
|
||||||
|
BaseURL: server.URL + "/v1", ModelID: "customer-model", APIKey: "integration-key", Temperature: 0.1, MaxTokens: 512,
|
||||||
|
}, "content-model-customer-create"); !errors.Is(err, ErrAccessDenied) {
|
||||||
|
t.Fatalf("customer content model create error=%v want access denied", err)
|
||||||
|
}
|
||||||
created, err := service.Create(ctx, principal, CreateInput{
|
created, err := service.Create(ctx, principal, CreateInput{
|
||||||
TenantID: "10000000-0000-0000-0000-000000000010", DisplayName: "集成测试模型", Provider: "openai_compatible",
|
TenantID: "10000000-0000-0000-0000-000000000010", DisplayName: "集成测试模型", Provider: "openai_compatible",
|
||||||
BaseURL: server.URL + "/v1", ModelID: "test-model", APIKey: "integration-key", Temperature: 0.1, MaxTokens: 512,
|
BaseURL: server.URL + "/v1", ModelID: "test-model", APIKey: "integration-key", Temperature: 0.1, MaxTokens: 512,
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error {
|
|||||||
}
|
}
|
||||||
sourceTurns := subjectTurns(snapshot.turns)
|
sourceTurns := subjectTurns(snapshot.turns)
|
||||||
if len(sourceTurns) == 0 {
|
if len(sourceTurns) == 0 {
|
||||||
return outbox.Retryable(ErrConversationContentNotReady)
|
// Terminal conversations cannot receive more subject turns. Treat the
|
||||||
|
// extraction request as complete so permanently empty conversations do
|
||||||
|
// not cycle through retry and dead-letter queues forever.
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
content := joinedConversationContent(snapshot.turns)
|
content := joinedConversationContent(snapshot.turns)
|
||||||
generated, err := p.generateSafely(ctx, content, snapshot.tenantID)
|
generated, err := p.generateSafely(ctx, content, snapshot.tenantID)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
|
|||||||
consentSnapshotID, consentSnapshotPublicID := uuid.NewString(), uuid.NewString()
|
consentSnapshotID, consentSnapshotPublicID := uuid.NewString(), uuid.NewString()
|
||||||
contextID, contextPublicID := uuid.NewString(), uuid.NewString()
|
contextID, contextPublicID := uuid.NewString(), uuid.NewString()
|
||||||
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
|
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
|
||||||
|
emptyConversationID, emptyConversationPublicID := uuid.NewString(), uuid.NewString()
|
||||||
defer func() {
|
defer func() {
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM conversation_content_assessment WHERE conversation_id=$1::uuid`, conversationID)
|
_, _ = pool.Exec(ctx, `DELETE FROM conversation_content_assessment WHERE conversation_id=$1::uuid`, conversationID)
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM story_draft WHERE conversation_id=$1::uuid)`, conversationID)
|
_, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM story_draft WHERE conversation_id=$1::uuid)`, conversationID)
|
||||||
@@ -49,6 +50,7 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
|
|||||||
_, _ = pool.Exec(ctx, `DELETE FROM conversation_summary WHERE conversation_id=$1::uuid`, conversationID)
|
_, _ = pool.Exec(ctx, `DELETE FROM conversation_summary WHERE conversation_id=$1::uuid`, conversationID)
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=$1::uuid`, conversationID)
|
_, _ = pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=$1::uuid`, conversationID)
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conversationID)
|
_, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conversationID)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, emptyConversationID)
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID)
|
_, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID)
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE id=$1::uuid`, consentSnapshotID)
|
_, _ = pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE id=$1::uuid`, consentSnapshotID)
|
||||||
}()
|
}()
|
||||||
@@ -74,6 +76,18 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
|
|||||||
conversationID, conversationPublicID, consentSnapshotID, contextID, "extract-task-"+conversationID); err != nil {
|
conversationID, conversationPublicID, consentSnapshotID, contextID, "extract-task-"+conversationID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,
|
||||||
|
provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason)
|
||||||
|
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201',
|
||||||
|
'10000000-0000-0000-0000-000000000401','10000000-0000-0000-0000-000000000501',
|
||||||
|
$3::uuid,$4::uuid,$5,'s2s_hybrid','transcript_only','COMPLETED',now()+interval '5 minutes',now()-interval '1 minute',now(),'user_hangup')`,
|
||||||
|
emptyConversationID, emptyConversationPublicID, consentSnapshotID, contextID, "extract-empty-task-"+emptyConversationID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := processor.Handle(ctx, outbox.Event{AggregateID: emptyConversationID, Payload: []byte(`{"generation_version":1}`)}); err != nil {
|
||||||
|
t.Fatalf("terminal conversation without subject turns should not retry: %v", err)
|
||||||
|
}
|
||||||
if _, err := pool.Exec(ctx, `
|
if _, err := pool.Exec(ctx, `
|
||||||
INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type)
|
INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type)
|
||||||
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,$2,1,'subject',now()-interval '2 minutes',now()-interval '110 seconds',$3,'provider'),
|
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,$2,1,'subject',now()-interval '2 minutes',now()-interval '110 seconds',$3,'provider'),
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const (
|
|||||||
CapabilityRecordingDownload Capability = "recording.download"
|
CapabilityRecordingDownload Capability = "recording.download"
|
||||||
CapabilityPrivacyRead Capability = "privacy.read"
|
CapabilityPrivacyRead Capability = "privacy.read"
|
||||||
CapabilityPrivacyManage Capability = "privacy.manage"
|
CapabilityPrivacyManage Capability = "privacy.manage"
|
||||||
CapabilityModelManage Capability = "model.manage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var roleCapabilities = map[Role]map[Capability]struct{}{
|
var roleCapabilities = map[Role]map[Capability]struct{}{
|
||||||
@@ -36,14 +35,12 @@ var roleCapabilities = map[Role]map[Capability]struct{}{
|
|||||||
CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite,
|
CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite,
|
||||||
CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage,
|
CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage,
|
||||||
CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage,
|
CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage,
|
||||||
CapabilityModelManage,
|
|
||||||
),
|
),
|
||||||
RoleAdmin: capabilitySet(
|
RoleAdmin: capabilitySet(
|
||||||
CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage,
|
CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage,
|
||||||
CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead,
|
CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead,
|
||||||
CapabilityStoryReview, CapabilityDeviceManage,
|
CapabilityStoryReview, CapabilityDeviceManage,
|
||||||
CapabilityFirmwareManage, CapabilityPrivacyRead,
|
CapabilityFirmwareManage, CapabilityPrivacyRead,
|
||||||
CapabilityModelManage,
|
|
||||||
),
|
),
|
||||||
RoleEditor: capabilitySet(
|
RoleEditor: capabilitySet(
|
||||||
CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite,
|
CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite,
|
||||||
|
|||||||
@@ -15,13 +15,12 @@ const sections = [
|
|||||||
["conversations", "回忆时间轴", "聊天记录"],
|
["conversations", "回忆时间轴", "聊天记录"],
|
||||||
["stories", "故事与记忆", "故事与记忆"],
|
["stories", "故事与记忆", "故事与记忆"],
|
||||||
["subjects", "长者资料", "长者资料"],
|
["subjects", "长者资料", "长者资料"],
|
||||||
["messages", "日程与提醒", "日程与提醒"],
|
["messages", "提醒与待办", "提醒与待办"],
|
||||||
["members", "家庭管理", "家庭管理"],
|
["members", "家庭管理", "家庭管理"],
|
||||||
["devices", "陪伴设备", "陪伴设备"],
|
["devices", "陪伴设备", "陪伴设备"],
|
||||||
["sharing", "内容分享", "内容分享"],
|
["sharing", "内容分享", "内容分享"],
|
||||||
["privacy", "数据与授权", "数据与授权"],
|
["privacy", "数据与授权", "数据与授权"],
|
||||||
["billing", "服务用量", "服务用量"],
|
["billing", "服务用量", "服务用量"],
|
||||||
["models", "大模型管理", "大模型管理"],
|
|
||||||
["profile", "设置与权限", "设置与权限"],
|
["profile", "设置与权限", "设置与权限"],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -97,6 +96,43 @@ try {
|
|||||||
results.push({ section, heading, layout });
|
results.push({ section, heading, layout });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await page.getByRole("menuitem", { name: /聊天记录$/ }).click();
|
||||||
|
await page.getByRole("heading", { name: "回忆时间轴", exact: true, level: 1 }).waitFor();
|
||||||
|
const timelineHeaders = page.locator(".timeline-day-group__header");
|
||||||
|
await timelineHeaders.first().waitFor();
|
||||||
|
const timelineHeaderCount = await timelineHeaders.count();
|
||||||
|
const timelineDefaultsWork = timelineHeaderCount >= 3
|
||||||
|
&& await timelineHeaders.nth(0).getAttribute("aria-expanded") === "true"
|
||||||
|
&& await timelineHeaders.nth(1).getAttribute("aria-expanded") === "true"
|
||||||
|
&& await timelineHeaders.nth(2).getAttribute("aria-expanded") === "false";
|
||||||
|
const timelineLayout = await page.locator(".memory-timeline-grid .conversation-list").evaluate((element) => ({
|
||||||
|
height: element.getBoundingClientRect().height,
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
overflowY: getComputedStyle(element.querySelector(".memory-timeline-scroll")).overflowY,
|
||||||
|
}));
|
||||||
|
const timelineIsBounded = timelineLayout.height <= 721 && timelineLayout.height < timelineLayout.viewportHeight && timelineLayout.overflowY === "auto";
|
||||||
|
await timelineHeaders.nth(2).click();
|
||||||
|
const olderTimelineExpands = await timelineHeaders.nth(2).getAttribute("aria-expanded") === "true";
|
||||||
|
await timelineHeaders.nth(2).click();
|
||||||
|
|
||||||
|
const portalSearch = page.getByPlaceholder("搜索长者、设备、通话", { exact: true });
|
||||||
|
await portalSearch.fill("设备");
|
||||||
|
await portalSearch.press("Enter");
|
||||||
|
await page.getByRole("heading", { name: "陪伴设备", exact: true, level: 1 }).waitFor();
|
||||||
|
const searchNavigationWorks = new URL(page.url()).searchParams.get("section") === "devices";
|
||||||
|
await page.getByRole("button", { name: "打开提醒与待办", exact: true }).click();
|
||||||
|
await page.getByRole("heading", { name: "提醒与待办", exact: true, level: 1 }).waitFor();
|
||||||
|
const reminderNavigationWorks = new URL(page.url()).searchParams.get("section") === "messages";
|
||||||
|
const reminderPaletteWorks = await page.evaluate(() => {
|
||||||
|
const controls = [...document.querySelectorAll(".reminder-todo, .reminder-history__summary > button")];
|
||||||
|
return controls.length > 0 && controls.every((item) => {
|
||||||
|
const channels = getComputedStyle(item).backgroundColor.match(/\d+/g)?.slice(0, 3).map(Number) ?? [];
|
||||||
|
return channels.length === 3 && channels.reduce((sum, value) => sum + value, 0) / 3 > 210;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerModelNavigationHidden = await page.getByRole("menuitem", { name: /大模型管理$/ }).count() === 0;
|
||||||
|
|
||||||
await page.getByRole("menuitem", { name: /长者资料$/ }).click();
|
await page.getByRole("menuitem", { name: /长者资料$/ }).click();
|
||||||
await page.getByRole("heading", { name: "长者资料", exact: true, level: 1 }).waitFor();
|
await page.getByRole("heading", { name: "长者资料", exact: true, level: 1 }).waitFor();
|
||||||
await page.locator("button.subject-add-button").click();
|
await page.locator("button.subject-add-button").click();
|
||||||
@@ -107,7 +143,8 @@ try {
|
|||||||
await page.screenshot({ path: resolve(outputDirectory, "subjects-avatar-upload.png") });
|
await page.screenshot({ path: resolve(outputDirectory, "subjects-avatar-upload.png") });
|
||||||
const disabledCursorResult = await page.evaluate(() => {
|
const disabledCursorResult = await page.evaluate(() => {
|
||||||
const controls = [...document.querySelectorAll("button:disabled")];
|
const controls = [...document.querySelectorAll("button:disabled")];
|
||||||
return { count: controls.length, allWait: controls.length > 0 && controls.every((item) => getComputedStyle(item).cursor === "wait") };
|
const cursors = controls.map((item) => ({ className: item.className, cursor: getComputedStyle(item).cursor }));
|
||||||
|
return { count: controls.length, cursors, allNotAllowed: controls.length > 0 && cursors.every((item) => item.cursor === "not-allowed") };
|
||||||
});
|
});
|
||||||
await page.getByRole("button", { name: "关闭新增长者弹窗" }).click();
|
await page.getByRole("button", { name: "关闭新增长者弹窗" }).click();
|
||||||
|
|
||||||
@@ -132,7 +169,6 @@ try {
|
|||||||
await checkModal("陪伴设备", ".workspace--devices .workspace__actions button:has-text('绑定设备')", "绑定设备", "绑定新设备");
|
await checkModal("陪伴设备", ".workspace--devices .workspace__actions button:has-text('绑定设备')", "绑定设备", "绑定新设备");
|
||||||
await checkModal("内容分享", ".subject-list-toolbar > button", "新建分享", "新建资源分享");
|
await checkModal("内容分享", ".subject-list-toolbar > button", "新建分享", "新建资源分享");
|
||||||
await checkModal("数据与授权", ".privacy-actions button:has-text('资料更正')", "资料更正", "提交资料更正");
|
await checkModal("数据与授权", ".privacy-actions button:has-text('资料更正')", "资料更正", "提交资料更正");
|
||||||
await checkModal("大模型管理", ".model-active-hero > button", "添加大模型", "添加总结大模型");
|
|
||||||
|
|
||||||
await page.getByRole("menuitem", { name: /故事与记忆$/ }).click();
|
await page.getByRole("menuitem", { name: /故事与记忆$/ }).click();
|
||||||
await page.locator("button.story-candidate-card").first().click();
|
await page.locator("button.story-candidate-card").first().click();
|
||||||
@@ -146,9 +182,9 @@ try {
|
|||||||
|
|
||||||
const uniqueResponseErrors = [...new Set(errorResponses)].filter((item) => !item.includes("/api/v1/events") && !(/^403 .*\/media$/.test(item)));
|
const uniqueResponseErrors = [...new Set(errorResponses)].filter((item) => !item.includes("/api/v1/events") && !(/^403 .*\/media$/.test(item)));
|
||||||
const relevantConsoleErrors = consoleErrors.filter((item) => !item.startsWith("Failed to load resource:"));
|
const relevantConsoleErrors = consoleErrors.filter((item) => !item.startsWith("Failed to load resource:"));
|
||||||
const report = { results, avatarPreviewVisible, modalChecks, disabledCursorResult, familyNavigationVisible, pageFailures, consoleErrors: relevantConsoleErrors, errorResponses: uniqueResponseErrors, failedRequests };
|
const report = { results, timelineHeaderCount, timelineDefaultsWork, timelineIsBounded, olderTimelineExpands, searchNavigationWorks, reminderNavigationWorks, reminderPaletteWorks, customerModelNavigationHidden, avatarPreviewVisible, modalChecks, disabledCursorResult, familyNavigationVisible, pageFailures, consoleErrors: relevantConsoleErrors, errorResponses: uniqueResponseErrors, failedRequests };
|
||||||
console.log(JSON.stringify(report, null, 2));
|
console.log(JSON.stringify(report, null, 2));
|
||||||
if (!avatarPreviewVisible || modalChecks.some((item) => !item.passed) || !disabledCursorResult.allWait || !familyNavigationVisible || pageFailures.length || relevantConsoleErrors.length || uniqueResponseErrors.length || failedRequests.length) {
|
if (!timelineDefaultsWork || !timelineIsBounded || !olderTimelineExpands || !searchNavigationWorks || !reminderNavigationWorks || !reminderPaletteWorks || !customerModelNavigationHidden || !avatarPreviewVisible || modalChecks.some((item) => !item.passed) || !disabledCursorResult.allNotAllowed || !familyNavigationVisible || pageFailures.length || relevantConsoleErrors.length || uniqueResponseErrors.length || failedRequests.length) {
|
||||||
throw new Error("Customer portal v0.74 visual/function smoke failed");
|
throw new Error("Customer portal v0.74 visual/function smoke failed");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
|
||||||
|
const username = process.env.VISUAL_USERNAME ?? "operator";
|
||||||
|
const password = process.env.VISUAL_PASSWORD ?? "OpsLocalOnly123!";
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
|
||||||
|
const consoleErrors = [];
|
||||||
|
const failedRequests = [];
|
||||||
|
const errorResponses = [];
|
||||||
|
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
|
||||||
|
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`));
|
||||||
|
page.on("response", (response) => { if (response.status() >= 400) errorResponses.push(`${response.status()} ${response.url()}`); });
|
||||||
|
page.on("requestfailed", (request) => {
|
||||||
|
const reason = request.failure()?.errorText ?? "failed";
|
||||||
|
const pathname = new URL(request.url()).pathname;
|
||||||
|
if (request.method() === "GET" && pathname === "/api/v1/events" && reason.includes("ERR_ABORTED")) return;
|
||||||
|
if (pathname.startsWith("/api/v1/") && reason.includes("ERR_ABORTED")) return;
|
||||||
|
failedRequests.push(`${request.method()} ${request.url()} ${reason}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const areas = [
|
||||||
|
["总览", "维护总览", null],
|
||||||
|
["设备管理", "设备管理", "devices"],
|
||||||
|
["运行配置", "运行配置", "config"],
|
||||||
|
["大模型管理", "大模型管理", "models"],
|
||||||
|
["内容质量", "内容质量", "quality"],
|
||||||
|
["用量、费用与额度", "用量、费用与额度", "billing"],
|
||||||
|
["告警处置", "告警处置", "alerts"],
|
||||||
|
["联调与诊断", "联调与诊断", "diagnostics"],
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(`${webURL}/maintenance`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.getByRole("button", { name: "登录维护中心", exact: true }).click();
|
||||||
|
await page.waitForURL(/localhost:58180|127\.0\.0\.1:58180/);
|
||||||
|
await page.locator("#username").fill(username);
|
||||||
|
await page.locator("#password").fill(password);
|
||||||
|
await page.locator("#kc-login").click();
|
||||||
|
await page.waitForURL(new RegExp(`^${new URL(webURL).origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/maintenance(?:[?#]|$)`), { timeout: 15000 });
|
||||||
|
await page.getByRole("heading", { name: "维护总览", exact: true, level: 1 }).waitFor({ timeout: 15000 });
|
||||||
|
|
||||||
|
const navigation = page.getByRole("navigation", { name: "维护中心主导航" });
|
||||||
|
const areaResults = [];
|
||||||
|
for (const [label, heading, area] of areas) {
|
||||||
|
await navigation.getByRole("button", { name: label, exact: true }).click();
|
||||||
|
await page.getByRole("heading", { name: heading, exact: true, level: 1 }).waitFor();
|
||||||
|
areaResults.push({ label, area: new URL(page.url()).searchParams.get("area"), expectedArea: area });
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = page.getByPlaceholder("搜索设备、通话、作业", { exact: true });
|
||||||
|
await search.fill("告警");
|
||||||
|
await search.press("Enter");
|
||||||
|
await page.getByRole("heading", { name: "告警处置", exact: true, level: 1 }).waitFor();
|
||||||
|
const alertSearchWorks = new URL(page.url()).searchParams.get("area") === "alerts";
|
||||||
|
await search.fill("10000000-0000-0000-0000-000000000511");
|
||||||
|
await search.press("Enter");
|
||||||
|
await page.getByRole("heading", { name: "联调与诊断", exact: true, level: 1 }).waitFor();
|
||||||
|
const identifierSearchWorks = new URL(page.url()).searchParams.get("area") === "diagnostics";
|
||||||
|
|
||||||
|
await navigation.getByRole("button", { name: "大模型管理", exact: true }).click();
|
||||||
|
await page.getByRole("heading", { name: "大模型管理", exact: true, level: 1 }).waitFor();
|
||||||
|
await page.getByRole("button", { name: /添加大模型/ }).click();
|
||||||
|
const modelDialog = page.getByRole("dialog", { name: "添加总结大模型", exact: true });
|
||||||
|
await modelDialog.waitFor();
|
||||||
|
const providerButtons = modelDialog.locator(".model-provider-group button");
|
||||||
|
const modelPaletteWorks = await providerButtons.evaluateAll((controls) => controls.length > 0 && controls.every((item) => {
|
||||||
|
const channels = getComputedStyle(item).backgroundColor.match(/\d+/g)?.slice(0, 3).map(Number) ?? [];
|
||||||
|
return channels.length === 3 && channels.reduce((sum, value) => sum + value, 0) / 3 > 210;
|
||||||
|
}));
|
||||||
|
await modelDialog.getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
|
||||||
|
await navigation.getByRole("button", { name: "设备管理", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: /新增设备/ }).click();
|
||||||
|
const createDialog = page.getByRole("dialog", { name: "新增设备", exact: true });
|
||||||
|
await createDialog.waitFor();
|
||||||
|
await createDialog.getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: /编辑/ }).first().click();
|
||||||
|
const editDialog = page.getByRole("dialog", { name: "编辑设备", exact: true });
|
||||||
|
await editDialog.waitFor();
|
||||||
|
await editDialog.getByRole("button", { name: "关闭", exact: true }).click();
|
||||||
|
|
||||||
|
await navigation.getByRole("button", { name: "联调与诊断", exact: true }).click();
|
||||||
|
await page.getByPlaceholder("输入完整编号", { exact: true }).fill("10000000-0000-0000-0000-000000000511");
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "导出脱敏诊断包", exact: true }).waitFor();
|
||||||
|
await page.getByRole("button", { name: "导出脱敏诊断包", exact: true }).click();
|
||||||
|
const exportForm = page.getByRole("form").last();
|
||||||
|
const exportSubmit = exportForm.getByRole("button", { name: "生成并下载", exact: true });
|
||||||
|
const exportInitiallyDisabled = await exportSubmit.isDisabled();
|
||||||
|
await exportForm.getByRole("textbox", { name: "导出原因", exact: true }).fill("页面功能测试,不下载");
|
||||||
|
const exportReady = await exportSubmit.isEnabled();
|
||||||
|
await exportForm.getByRole("button", { name: "取消", exact: true }).click();
|
||||||
|
|
||||||
|
const areaFailures = areaResults.filter((item) => item.area !== item.expectedArea);
|
||||||
|
const relevantResponseErrors = [...new Set(errorResponses)].filter((item) => !item.includes("/api/v1/events") && !(item.startsWith("404 ") && item.endsWith("/api/v1/ops/diagnostics/platform-health")));
|
||||||
|
const relevantConsoleErrors = consoleErrors.filter((item) => !item.startsWith("Failed to load resource:"));
|
||||||
|
const report = { areaResults, areaFailures, alertSearchWorks, identifierSearchWorks, modelPaletteWorks, exportInitiallyDisabled, exportReady, consoleErrors: relevantConsoleErrors, errorResponses: relevantResponseErrors, failedRequests };
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
if (areaFailures.length || !alertSearchWorks || !identifierSearchWorks || !modelPaletteWorks || !exportInitiallyDisabled || !exportReady || relevantConsoleErrors.length || relevantResponseErrors.length || failedRequests.length) {
|
||||||
|
throw new Error("Maintenance navigation/function smoke failed");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -583,7 +583,7 @@ input[type="checkbox"], input[type="radio"], input[type="range"] { accent-color:
|
|||||||
.workspace__header h2 { font-size: 25px !important; }
|
.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; }
|
.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 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. */
|
/* Shared objects use one clear surface; avoid nested full-page cards. */
|
||||||
.customer-page .panel-section,
|
.customer-page .panel-section,
|
||||||
|
|||||||
+14
-3
@@ -1,4 +1,4 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode, useEffect, useState } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { ConfigProvider } from "antd";
|
import { ConfigProvider } from "antd";
|
||||||
import zhCN from "antd/locale/zh_CN";
|
import zhCN from "antd/locale/zh_CN";
|
||||||
@@ -7,7 +7,18 @@ import { MaintenancePortal } from "./pages/MaintenancePortal";
|
|||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
import "./cal-theme.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(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
@@ -67,7 +78,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Portal />
|
<PortalRouter />
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
SafetyCertificateOutlined,
|
SafetyCertificateOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
RobotOutlined,
|
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
UserOutlined
|
UserOutlined
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
@@ -62,7 +61,6 @@ const sectionMeta: Record<PortalSection, { title: string; description: string; b
|
|||||||
privacy: { title: "数据与授权", description: "按项目查看授权,并申请访问、更正、撤回、删除或私有导出。", breadcrumb: "隐私与授权 / 数据与授权" },
|
privacy: { title: "数据与授权", description: "按项目查看授权,并申请访问、更正、撤回、删除或私有导出。", breadcrumb: "隐私与授权 / 数据与授权" },
|
||||||
billing: { title: "服务用量", description: "围绕长者聊天查看时长、活跃日和最近通话。", breadcrumb: "服务用量" },
|
billing: { title: "服务用量", description: "围绕长者聊天查看时长、活跃日和最近通话。", breadcrumb: "服务用量" },
|
||||||
messages: { title: "提醒与待办", description: "查看服务端记录的陪伴动态,并处理故事、设备和隐私事项。", breadcrumb: "提醒与待办" },
|
messages: { title: "提醒与待办", description: "查看服务端记录的陪伴动态,并处理故事、设备和隐私事项。", breadcrumb: "提醒与待办" },
|
||||||
models: { title: "大模型管理", description: "配置、测试并激活用于聊天总结、关键词和故事候选生成的大模型。", breadcrumb: "设置与权限 / 大模型管理" },
|
|
||||||
profile: { title: "设置与权限", description: "查看登录身份、家庭角色和全部权限范围。", breadcrumb: "设置与权限" }
|
profile: { title: "设置与权限", description: "查看登录身份、家庭角色和全部权限范围。", breadcrumb: "设置与权限" }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,7 +97,6 @@ export function CustomerPortal() {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ key: "billing", icon: <CreditCardOutlined />, label: "服务用量" },
|
{ key: "billing", icon: <CreditCardOutlined />, label: "服务用量" },
|
||||||
{ key: "models", icon: <RobotOutlined />, label: "大模型管理" },
|
|
||||||
{ key: "profile", icon: <SettingOutlined />, label: "设置与权限" }
|
{ key: "profile", icon: <SettingOutlined />, label: "设置与权限" }
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
@@ -123,7 +120,6 @@ export function CustomerPortal() {
|
|||||||
[["分享", "share"], "sharing"],
|
[["分享", "share"], "sharing"],
|
||||||
[["费用", "额度", "账单", "cost"], "billing"],
|
[["费用", "额度", "账单", "cost"], "billing"],
|
||||||
[["消息", "通知", "提醒"], "messages"],
|
[["消息", "通知", "提醒"], "messages"],
|
||||||
[["大模型", "模型", "豆包", "总结"], "models"],
|
|
||||||
[["长者", "老人", "档案", "subject"], "subjects"],
|
[["长者", "老人", "档案", "subject"], "subjects"],
|
||||||
[["家庭", "成员", "邀请", "member"], "members"]
|
[["家庭", "成员", "邀请", "member"], "members"]
|
||||||
];
|
];
|
||||||
@@ -271,11 +267,10 @@ const permissionRows = [
|
|||||||
["privacy.manage", "处理隐私请求", true, false, false, false],
|
["privacy.manage", "处理隐私请求", true, false, false, false],
|
||||||
["recording.play", "播放聊天录音", false, false, false, false],
|
["recording.play", "播放聊天录音", false, false, false, false],
|
||||||
["recording.download", "下载聊天录音", false, false, false, false],
|
["recording.download", "下载聊天录音", false, false, false, false],
|
||||||
["model.manage", "管理总结大模型", true, true, false, false],
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function PermissionMatrix() {
|
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>
|
<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>
|
<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>;
|
</Card>;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
RobotOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
@@ -42,8 +43,9 @@ import { OpsBillingWorkspace } from "./OpsBillingWorkspace";
|
|||||||
import { OpsConfigWorkspace } from "./OpsConfigWorkspace";
|
import { OpsConfigWorkspace } from "./OpsConfigWorkspace";
|
||||||
import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace";
|
import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace";
|
||||||
import { OpsDeviceWorkspace } from "./OpsDeviceWorkspace";
|
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 = {
|
type HealthState = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -59,6 +61,7 @@ const areaMeta: Record<MaintenanceArea, { title: string; description: string; ic
|
|||||||
overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> },
|
overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> },
|
||||||
devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> },
|
devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> },
|
||||||
config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: <SettingOutlined /> },
|
config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: <SettingOutlined /> },
|
||||||
|
models: { title: "大模型管理", description: "按客户组织配置、测试并激活聊天总结、关键词和故事候选生成模型。", icon: <RobotOutlined /> },
|
||||||
quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: <BarChartOutlined /> },
|
quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: <BarChartOutlined /> },
|
||||||
billing: { title: "用量、费用与额度", description: "查看整体通话时长并按家庭、设备筛选,同时维护核价与硬额度策略。", icon: <DollarOutlined /> },
|
billing: { title: "用量、费用与额度", description: "查看整体通话时长并按家庭、设备筛选,同时维护核价与硬额度策略。", icon: <DollarOutlined /> },
|
||||||
alerts: { title: "告警处置", description: "维护告警路由、通知测试、静默窗口和处置时间线。", icon: <AlertOutlined /> },
|
alerts: { title: "告警处置", description: "维护告警路由、通知测试、静默窗口和处置时间线。", icon: <AlertOutlined /> },
|
||||||
@@ -69,6 +72,7 @@ export function MaintenancePortal() {
|
|||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720);
|
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720);
|
||||||
const [area, setArea] = useState<MaintenanceArea>(() => areaFromURL());
|
const [area, setArea] = useState<MaintenanceArea>(() => areaFromURL());
|
||||||
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [health, setHealth] = useState<HealthState>({ loading: true });
|
const [health, setHealth] = useState<HealthState>({ loading: true });
|
||||||
|
|
||||||
const refreshHealth = useCallback(async (signal?: AbortSignal) => {
|
const refreshHealth = useCallback(async (signal?: AbortSignal) => {
|
||||||
@@ -120,11 +124,28 @@ export function MaintenancePortal() {
|
|||||||
window.history.pushState({ area: next }, "", `${url.pathname}${url.search}${url.hash}`);
|
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"] = [
|
const menuItems: MenuProps["items"] = [
|
||||||
{ type: "group", label: "平台维护", children: [
|
{ type: "group", label: "平台维护", children: [
|
||||||
{ key: "overview", icon: <DashboardOutlined />, label: "维护总览" },
|
{ key: "overview", icon: <DashboardOutlined />, label: "维护总览" },
|
||||||
{ key: "devices", icon: <DatabaseOutlined />, label: "设备管理" },
|
{ key: "devices", icon: <DatabaseOutlined />, label: "设备管理" },
|
||||||
{ key: "config", icon: <SettingOutlined />, label: "运行配置" },
|
{ key: "config", icon: <SettingOutlined />, label: "运行配置" },
|
||||||
|
{ key: "models", icon: <RobotOutlined />, label: "大模型管理" },
|
||||||
{ key: "quality", icon: <BarChartOutlined />, label: "内容质量" },
|
{ key: "quality", icon: <BarChartOutlined />, label: "内容质量" },
|
||||||
{ key: "billing", icon: <DollarOutlined />, label: "用量与费用" },
|
{ key: "billing", icon: <DollarOutlined />, label: "用量与费用" },
|
||||||
{ key: "alerts", icon: <AlertOutlined />, 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>)}
|
{(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>
|
</nav>
|
||||||
<div className="maintenance-header__actions">
|
<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>
|
<Tooltip title="平台就绪状态"><Badge status={health.ready === "ready" ? "success" : health.loading ? "processing" : "warning"} /></Tooltip>
|
||||||
<Button type="text" icon={<BellOutlined />} aria-label="查看告警" onClick={() => navigate("alerts")} />
|
<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>}
|
{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 === "overview" ? <MaintenanceOverview health={health} accounts={operationsAccounts} /> : null}
|
||||||
{area === "devices" ? <OpsDeviceWorkspace accessToken={auth.oidcUser.access_token} 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 === "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 === "quality" ? <ContentQualityWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||||
{area === "billing" ? <OpsBillingWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
{area === "billing" ? <OpsBillingWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||||
{area === "alerts" ? <OpsAlertWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
{area === "alerts" ? <OpsAlertWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
|
||||||
|
|||||||
@@ -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 : "大模型配置操作失败";
|
||||||
|
}
|
||||||
@@ -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 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 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)}
|
function openDiagnosticControl(kind:DiagnosticControlKind,item:OpsDiagnosticRecord){setDiagnosticControlDraft({kind,item,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)}
|
||||||
async function executeDiagnosticControl(event:FormEvent){
|
async function executeDiagnosticControl(event:FormEvent){
|
||||||
event.preventDefault();const draft=diagnosticControlDraft;if(!draft||!draft.reason.trim()||(diagnosticControlNeedsAcknowledgement(draft.kind)&&!draft.acknowledged))return;
|
event.preventDefault();const draft=diagnosticControlDraft;if(!draft||!draft.reason.trim()||(diagnosticControlNeedsAcknowledgement(draft.kind)&&!draft.acknowledged))return;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ import {
|
|||||||
import { contentModelProviderLabel, contentModelProviderPreset, contentModelProviderPresets } from "../contentModelProviders";
|
import { contentModelProviderLabel, contentModelProviderPreset, contentModelProviderPresets } from "../contentModelProviders";
|
||||||
import { FamilyUsageOverview } from "../components/UsageOverview";
|
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 Tab = CustomerSection | "firmware";
|
||||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||||
type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "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 [turns, setTurns] = useState<ConversationTurn[]>([]);
|
||||||
const [contentSummaries, setContentSummaries] = useState<ConversationContentSummary[]>([]);
|
const [contentSummaries, setContentSummaries] = useState<ConversationContentSummary[]>([]);
|
||||||
const [contentModels, setContentModels] = useState<ContentModel[]>([]);
|
const [contentModels, setContentModels] = useState<ContentModel[]>([]);
|
||||||
const [modelLoading, setModelLoading] = useState(false);
|
|
||||||
const [modelError, setModelError] = useState<string>();
|
|
||||||
const [conversationInsights, setConversationInsights] = useState<Record<string, ConversationInsight>>({});
|
const [conversationInsights, setConversationInsights] = useState<Record<string, ConversationInsight>>({});
|
||||||
const [conversationMedia, setConversationMedia] = useState<ConversationMedia>();
|
const [conversationMedia, setConversationMedia] = useState<ConversationMedia>();
|
||||||
const [mediaState, setMediaState] = useState<MediaLoadState>("idle");
|
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 canManageFirmware = enableFirmware && (selectedHousehold?.capabilities.includes("firmware.manage") ?? false);
|
||||||
const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
|
const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
|
||||||
const canManageSharing = 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 policyVersion = accounts.find((account) => account.tenant_id === selectedHousehold?.tenant_id)?.policy_version ?? "";
|
||||||
const consentNotice = `我已阅读隐私政策${policyVersion || "当前"}版本,并同意对后续新通话启用“${consentLabel(newConsentType)}”。${consentImpactLabel(newConsentType)}我知道该授权可以单独撤回。`;
|
const consentNotice = `我已阅读隐私政策${policyVersion || "当前"}版本,并同意对后续新通话启用“${consentLabel(newConsentType)}”。${consentImpactLabel(newConsentType)}我知道该授权可以单独撤回。`;
|
||||||
const selectedConsentAlreadyEffective = consents.some((item) => item.type === newConsentType && item.effective);
|
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) => {
|
const loadContentModels = useCallback(async (signal?: AbortSignal) => {
|
||||||
if (!selectedHousehold?.tenant_id) { setContentModels([]); return; }
|
if (!selectedHousehold?.tenant_id) { setContentModels([]); return; }
|
||||||
setModelLoading(true); setModelError(undefined);
|
|
||||||
try { setContentModels((await platformApi.contentModels(selectedHousehold.tenant_id, accessToken, signal)).data.items); }
|
try { setContentModels((await platformApi.contentModels(selectedHousehold.tenant_id, accessToken, signal)).data.items); }
|
||||||
catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) setModelError(errorMessage(cause)); }
|
catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) setContentModels([]); }
|
||||||
finally { if (!signal?.aborted) setModelLoading(false); }
|
|
||||||
}, [accessToken, selectedHousehold?.tenant_id]);
|
}, [accessToken, selectedHousehold?.tenant_id]);
|
||||||
|
|
||||||
useEffect(() => { const controller = new AbortController(); void loadContentModels(controller.signal); return () => controller.abort(); }, [loadContentModels, refreshRevision]);
|
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() {
|
async function regenerateSummary() {
|
||||||
if (!selectedConversationID) return;
|
if (!selectedConversationID) return;
|
||||||
await runAction("已使用当前激活模型排队重新总结;结果生成后会自动刷新。", async () => {
|
await runAction("已使用当前激活模型排队重新总结;结果生成后会自动刷新。", async () => {
|
||||||
@@ -1509,7 +1488,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
|
|||||||
stories={stories} onOpenStories={() => selectSection("stories")}
|
stories={stories} onOpenStories={() => selectSection("stories")}
|
||||||
onRevoke={revokeRecordingAccess} onAccess={accessRecording} />
|
onRevoke={revokeRecordingAccess} onAccess={accessRecording} />
|
||||||
) : null}
|
) : 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}
|
{tab === "stories" ? <StoryPanel selectedID={selectedSubjectID}
|
||||||
stories={stories} memories={memories} selected={selectedStory} openRequestID={storyReviewRequestID} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory}
|
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}
|
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) {
|
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) {
|
function customerSectionDescription(section: Tab) {
|
||||||
@@ -1651,8 +1629,7 @@ function customerSectionDescription(section: Tab) {
|
|||||||
sharing: "按资源和权限范围向指定家庭成员分享内容。",
|
sharing: "按资源和权限范围向指定家庭成员分享内容。",
|
||||||
privacy: "提交访问、导出或删除申请并跟踪处理进度。",
|
privacy: "提交访问、导出或删除申请并跟踪处理进度。",
|
||||||
billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。",
|
billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。",
|
||||||
messages: "集中查看服务端记录的陪伴动态,并处理设备、故事和隐私请求待办。",
|
messages: "集中查看服务端记录的陪伴动态,并处理设备、故事和隐私请求待办。"
|
||||||
models: "配置、测试并激活用于聊天摘要、关键词和故事候选生成的大模型。"
|
|
||||||
} as Record<Tab, string>)[section];
|
} as Record<Tab, string>)[section];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2130,11 +2107,13 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
|
|||||||
stories: Story[]; onOpenStories: () => void;
|
stories: Story[]; onOpenStories: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [timelineDayState, setTimelineDayState] = useState<Record<string, boolean>>({});
|
||||||
const currentSummary = summaries.find((item) => item.type === "conversation_summary");
|
const currentSummary = summaries.find((item) => item.type === "conversation_summary");
|
||||||
const currentTopic = summaries.find((item) => item.type === "open_topic");
|
const currentTopic = summaries.find((item) => item.type === "open_topic");
|
||||||
const summaryParts = currentSummary?.content.split("\n关键词:") ?? [];
|
const summaryParts = currentSummary?.content.split("\n关键词:") ?? [];
|
||||||
const keywords = summaryParts[1]?.split("、").filter(Boolean) ?? [];
|
const keywords = summaryParts[1]?.split("、").filter(Boolean) ?? [];
|
||||||
const storyCandidate = detail ? stories.find((item) => item.conversation_id === detail.id) : undefined;
|
const storyCandidate = detail ? stories.find((item) => item.conversation_id === detail.id) : undefined;
|
||||||
|
const timelineGroups = groupConversationsByDay(conversations);
|
||||||
return <div className="conversation-layout memory-timeline-page">
|
return <div className="conversation-layout memory-timeline-page">
|
||||||
<div className="memory-timeline-grid">
|
<div className="memory-timeline-grid">
|
||||||
<div className="conversation-list">
|
<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={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>
|
<label><span>结束日期</span><input disabled={loadingMore} type="date" value={to} min={from || undefined} onChange={(event) => onTo(event.target.value)} /></label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{error ? <div className="conversation-list__error" role="alert">{error}</div> : null}
|
{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>}
|
{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>}
|
||||||
{hasMore ? <div className="conversation-list__more"><button type="button" className="button-secondary" disabled={loading || loadingMore} onClick={() => void onLoadMore()}>{loadingMore ? "正在加载…" : "加载更多记录"}</button></div> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<section className="memory-summary-panel" aria-label="智能总结">
|
<section className="memory-summary-panel" aria-label="智能总结">
|
||||||
{detailLoading ? <StatePanel title="正在生成回忆卡片" detail="读取聊天、摘要和故事候选…" /> : detail ? <>
|
{detailLoading ? <StatePanel title="正在生成回忆卡片" detail="读取聊天、摘要和故事候选…" /> : detail ? <>
|
||||||
@@ -2180,7 +2158,7 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
|
|||||||
</div>;
|
</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;
|
models: ContentModel[]; loading: boolean; error?: string; canManage: boolean; actionBusy: boolean;
|
||||||
onCreate: (input: CreateContentModelRequest) => Promise<boolean>; onTest: (item: ContentModel) => Promise<void>;
|
onCreate: (input: CreateContentModelRequest) => Promise<boolean>; onTest: (item: ContentModel) => Promise<void>;
|
||||||
onActivate: (item: ContentModel) => Promise<void>; onRevoke: (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)}”`);
|
if (JSON.stringify(nextAvoidedTopics) !== JSON.stringify(currentAvoidedTopics)) changes.push(`避免主动提及“${contextItemsLabel(currentAvoidedTopics)}”→“${contextItemsLabel(nextAvoidedTopics)}”`);
|
||||||
return changes.length ? changes : ["无有效变化"];
|
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 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 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; }
|
function factTypeLabel(value: string) { return ({ family_note: "家庭信息", preference: "个人偏好", life_event: "人生事件", health_context: "健康背景" } as Record<string, string>)[value] ?? value; }
|
||||||
|
|||||||
+45
-5
@@ -30,7 +30,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button:hover { transform: translateY(-1px); }
|
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; }
|
.button-secondary { color: #244356; background: #e5ece9; }
|
||||||
|
|
||||||
main {
|
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-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 { 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 > * { 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 { border-bottom: 1px solid #eee4e1; background: #faf6f2; }
|
||||||
.memory-timeline-grid .conversation-filter__fields { grid-template-columns: 1fr 1fr; }
|
.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-grid .conversation-filter__fields label:nth-child(n+3) { display: none; }
|
||||||
.memory-timeline-list { position: relative; padding: 8px 0 12px; }
|
.memory-timeline-scroll { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
|
||||||
.memory-timeline-list::before { position: absolute; top: 15px; bottom: 22px; left: 25px; width: 2px; content: ""; background: #dfcbc4; }
|
.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 { 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.active { background: #f7ebe6; box-shadow: inset 3px 0 #a73920; }
|
||||||
.memory-timeline-list .conversation-row > .tag { grid-column: 3; justify-self: start; }
|
.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:not(.ant-btn) { border-radius: 8px !important; }
|
||||||
.customer-layout button:disabled,
|
.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 { 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 input,.subject-avatar-editor input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||||
.subject-avatar-upload > .anticon { font-size: 27px; }
|
.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-timeline-grid { grid-template-columns: 1fr; }
|
||||||
.customer-page--conversations .memory-model-card { grid-column: auto; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user