feat: complete ops dashboard AI board
This commit is contained in:
@@ -5,7 +5,7 @@ import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
|
||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
||||
import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context";
|
||||
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
|
||||
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
|
||||
import { generateElderAiAnalysis, listAiAnalysisBoard, listElderAiAnalyses } from "@/modules/ai/server/analysis";
|
||||
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
|
||||
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
@@ -65,6 +65,11 @@ type AnalysisRow = {
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type ElderNameRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type AnalysisInsertPayload = {
|
||||
organizationId?: unknown;
|
||||
elderId?: unknown;
|
||||
@@ -258,8 +263,9 @@ function createAnalysisRow(values: AnalysisInsertPayload, index: number): Analys
|
||||
};
|
||||
}
|
||||
|
||||
function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFake {
|
||||
function createDatabaseFake(selectRows: AnalysisRow[] = [], elderRows: ElderNameRow[] = []): AnalysisDatabaseFake {
|
||||
const insertedValues: AnalysisInsertPayload[] = [];
|
||||
const orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
|
||||
const insert = vi.fn(() => ({
|
||||
values: vi.fn((values: AnalysisInsertPayload) => {
|
||||
insertedValues.push(values);
|
||||
@@ -268,15 +274,23 @@ function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFak
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const select = vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
orderBy: vi.fn(() => ({
|
||||
limit: vi.fn(async () => selectRows),
|
||||
})),
|
||||
const select = vi.fn((selection?: unknown) => {
|
||||
const selectsElderNames = selection !== null && typeof selection === "object" && "name" in selection;
|
||||
return {
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => {
|
||||
if (selectsElderNames) {
|
||||
return elderRows;
|
||||
}
|
||||
return {
|
||||
orderBy: vi.fn(() => ({
|
||||
limit: vi.fn(async (rowLimit: number) => orderedSelectRows.slice(0, rowLimit)),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
return { insertedValues, insert, select };
|
||||
}
|
||||
@@ -442,6 +456,130 @@ describe("elder AI analysis service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists board analysis rows with elder display names and permitted result content", async () => {
|
||||
const analysisResult = createModelOutput({
|
||||
overallRiskLevel: "high",
|
||||
summary: "Night wandering requires a care-plan review.",
|
||||
});
|
||||
const completedRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-2",
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
resultJson: analysisResult,
|
||||
citationsJson: analysisResult.citations,
|
||||
modelSummaryJson: analysisResult.modelSummary,
|
||||
}, 1);
|
||||
useDatabase(createDatabaseFake([completedRow], [{ id: "elder-2", name: "李建国" }]));
|
||||
|
||||
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 5);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
id: "analysis-2",
|
||||
elderId: "elder-2",
|
||||
elderName: "李建国",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
createdAt: "2026-07-02T01:00:00.000Z",
|
||||
restricted: false,
|
||||
result: analysisResult,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts board analysis rows when a stored data scope is not permitted", async () => {
|
||||
const restrictedResult = createModelOutput({
|
||||
summary: "Health details should not be visible without health permission.",
|
||||
});
|
||||
const restrictedRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-3",
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
resultJson: restrictedResult,
|
||||
citationsJson: restrictedResult.citations,
|
||||
modelSummaryJson: restrictedResult.modelSummary,
|
||||
errorCategory: "provider_error",
|
||||
errorReason: "provider detail should stay hidden",
|
||||
}, 2);
|
||||
useDatabase(createDatabaseFake([restrictedRow], [{ id: "elder-3", name: "周玉珍" }]));
|
||||
|
||||
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 5);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success !== true) {
|
||||
return;
|
||||
}
|
||||
expect(result.data.items).toEqual([
|
||||
{
|
||||
id: "analysis-3",
|
||||
elderId: "elder-3",
|
||||
elderName: "周玉珍",
|
||||
status: "completed",
|
||||
dataScopes: ["elder", "health"],
|
||||
createdAt: "2026-07-02T02:00:00.000Z",
|
||||
restricted: true,
|
||||
},
|
||||
]);
|
||||
expect(result.data.items[0]).not.toHaveProperty("result");
|
||||
expect(result.data.items[0]).not.toHaveProperty("errorCategory");
|
||||
expect(result.data.items[0]).not.toHaveProperty("errorReason");
|
||||
});
|
||||
|
||||
it("returns board rows in newest-first order up to the requested limit", async () => {
|
||||
const newestRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-4",
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder"],
|
||||
resultJson: createModelOutput({ summary: "Newest analysis" }),
|
||||
}, 3);
|
||||
const olderRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-1",
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder"],
|
||||
resultJson: createModelOutput({ summary: "Older analysis" }),
|
||||
}, 0);
|
||||
const middleRow = createAnalysisRow({
|
||||
organizationId: "org-1",
|
||||
elderId: "elder-2",
|
||||
actorAccountId: "account-1",
|
||||
status: "completed",
|
||||
dataScopes: ["elder"],
|
||||
resultJson: createModelOutput({ summary: "Middle analysis" }),
|
||||
}, 1);
|
||||
useDatabase(createDatabaseFake(
|
||||
[olderRow, newestRow, middleRow],
|
||||
[
|
||||
{ id: "elder-1", name: "王阿姨" },
|
||||
{ id: "elder-2", name: "李建国" },
|
||||
{ id: "elder-4", name: "陈桂兰" },
|
||||
],
|
||||
));
|
||||
|
||||
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 2);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success !== true) {
|
||||
return;
|
||||
}
|
||||
expect(result.data.items.map((item) => ({ id: item.id, elderName: item.elderName, createdAt: item.createdAt }))).toEqual([
|
||||
{ id: "analysis-4", elderName: "陈桂兰", createdAt: "2026-07-02T03:00:00.000Z" },
|
||||
{ id: "analysis-2", elderName: "李建国", createdAt: "2026-07-02T01:00:00.000Z" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects model output that cites IDs outside resident and knowledge citations", async () => {
|
||||
const database = createDatabaseFake();
|
||||
useDatabase(database);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
|
||||
import type {
|
||||
AiCitation,
|
||||
AiErrorCategory,
|
||||
ElderAiAnalysisBoardItem,
|
||||
ElderAiAnalysisHistoryItem,
|
||||
ElderAiAnalysisOutput,
|
||||
} from "@/modules/ai/types";
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
} from "@/modules/ai/types";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import { elderAiAnalyses } from "@/modules/core/server/schema";
|
||||
import { elderAiAnalyses, elders } from "@/modules/core/server/schema";
|
||||
import type { AuthContext } from "@/modules/core/types";
|
||||
|
||||
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
|
||||
@@ -202,6 +203,48 @@ export async function listElderAiAnalyses(
|
||||
};
|
||||
}
|
||||
|
||||
function rowToBoardItem(row: AnalysisRow, elderName: string, permissions: AuthContext["permissions"]): ElderAiAnalysisBoardItem {
|
||||
return {
|
||||
elderName,
|
||||
...rowToHistoryItem(row, permissions),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAiAnalysisBoard(
|
||||
context: AuthContext,
|
||||
limit = 8,
|
||||
): Promise<ServiceResult<{ items: ElderAiAnalysisBoardItem[] }>> {
|
||||
if (!context.permissions.includes("ai:read")) {
|
||||
return { success: false, reason: "无权查看 AI 分析", status: 403 };
|
||||
}
|
||||
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
|
||||
}
|
||||
|
||||
const normalizedLimit = Number.isFinite(limit) ? Math.trunc(limit) : 8;
|
||||
const rowLimit = Math.min(20, Math.max(1, normalizedLimit));
|
||||
const database = getDatabase();
|
||||
const [rows, elderRows] = await Promise.all([
|
||||
database
|
||||
.select()
|
||||
.from(elderAiAnalyses)
|
||||
.where(eq(elderAiAnalyses.organizationId, organizationId))
|
||||
.orderBy(desc(elderAiAnalyses.createdAt))
|
||||
.limit(rowLimit),
|
||||
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
|
||||
]);
|
||||
const elderNameById = new Map(elderRows.map((elder) => [elder.id, elder.name]));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: rows.map((row) => rowToBoardItem(row, elderNameById.get(row.elderId) ?? "未知老人", context.permissions)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateElderAiAnalysis(
|
||||
context: AuthContext,
|
||||
elderId: string,
|
||||
|
||||
@@ -93,6 +93,10 @@ export type ElderAiAnalysisHistoryItem = {
|
||||
errorReason?: string;
|
||||
};
|
||||
|
||||
export type ElderAiAnalysisBoardItem = ElderAiAnalysisHistoryItem & {
|
||||
elderName: string;
|
||||
};
|
||||
|
||||
export type KnowledgeEntryInput = {
|
||||
scope: AiKnowledgeScope;
|
||||
organizationId?: string;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import { getDatabase, type AppDatabase } from "@/modules/core/server/db";
|
||||
import {
|
||||
DEFAULT_AI_KNOWLEDGE_ENTRIES,
|
||||
DEFAULT_ALERT_RULES,
|
||||
DEFAULT_ALERT_TRIGGERS,
|
||||
DEFAULT_DEVICE_ASSETS,
|
||||
DEFAULT_FAMILY_CONTACTS,
|
||||
DEFAULT_FAMILY_FEEDBACK,
|
||||
DEFAULT_FAMILY_VISITS,
|
||||
DEFAULT_MAINTENANCE_TICKETS,
|
||||
DEFAULT_NOTICES,
|
||||
DEFAULT_PREPARED_AI_ANALYSES,
|
||||
seedDefaultCollaborationWorkspaceData,
|
||||
} from "@/modules/core/server/default-workspace-data";
|
||||
|
||||
const FORBIDDEN_DEMO_WORDING_PATTERN = /mock|simulation|simulated|demo|sample|placeholder|fixture|fake|test data|模拟|仿真|演示|样例|示例|占位|测试数据/i;
|
||||
@@ -13,6 +23,258 @@ vi.mock("@/modules/core/server/db", () => ({
|
||||
getDatabase: vi.fn(),
|
||||
}));
|
||||
|
||||
function expectStableUniqueNaturalKeys<T>(items: T[], getKey: (item: T) => string, expectedKeys: string[]): void {
|
||||
const keys = items.map(getKey);
|
||||
expect(keys).toEqual(expectedKeys);
|
||||
expect(new Set(keys)).toHaveLength(items.length);
|
||||
|
||||
for (const key of keys) {
|
||||
expect(key.trim()).toBe(key);
|
||||
expect(key).not.toBe("");
|
||||
}
|
||||
}
|
||||
|
||||
type SeedInsertRow = Record<string, unknown>;
|
||||
|
||||
function isSeedInsertRow(value: unknown): value is SeedInsertRow {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toSeedInsertRows(values: unknown): SeedInsertRow[] {
|
||||
return Array.isArray(values) ? values.filter(isSeedInsertRow) : [];
|
||||
}
|
||||
|
||||
function rowsWithKey(rows: SeedInsertRow[], key: string): SeedInsertRow[] {
|
||||
return rows.filter((row) => key in row);
|
||||
}
|
||||
|
||||
function createCollaborationSeedDatabaseMock(params: {
|
||||
missingDeviceCode: string;
|
||||
missingTicketTitle: string;
|
||||
missingNoticeTitle: string;
|
||||
missingAlertRuleName: string;
|
||||
missingAlertTriggerTitle: string;
|
||||
missingFamilyContactName: string;
|
||||
missingFamilyVisitNotes: string;
|
||||
missingFamilyFeedbackContent: string;
|
||||
}): { database: AppDatabase; insertCalls: Array<{ values: SeedInsertRow[] }> } {
|
||||
const elderNames = Array.from(
|
||||
new Set([
|
||||
...DEFAULT_FAMILY_CONTACTS.map((contact) => contact.elderName),
|
||||
...DEFAULT_ALERT_TRIGGERS.flatMap((trigger) => (trigger.elderName ? [trigger.elderName] : [])),
|
||||
]),
|
||||
);
|
||||
const elderRows = elderNames.map((name, index) => ({ id: `elder-${index}`, name }));
|
||||
const getElderId = (name: string): string => {
|
||||
const elder = elderRows.find((row) => row.name === name);
|
||||
if (!elder) {
|
||||
throw new Error(`Missing test elder row for ${name}`);
|
||||
}
|
||||
|
||||
return elder.id;
|
||||
};
|
||||
|
||||
const allFamilyContactRows = DEFAULT_FAMILY_CONTACTS.map((contact, index) => ({
|
||||
id: `contact-${index}`,
|
||||
elderId: getElderId(contact.elderName),
|
||||
name: contact.name,
|
||||
}));
|
||||
const getContactId = (elderName: string, contactName: string): string => {
|
||||
const elderId = getElderId(elderName);
|
||||
const contact = allFamilyContactRows.find((row) => row.elderId === elderId && row.name === contactName);
|
||||
if (!contact) {
|
||||
throw new Error(`Missing test contact row for ${elderName}/${contactName}`);
|
||||
}
|
||||
|
||||
return contact.id;
|
||||
};
|
||||
|
||||
const selectResults = [
|
||||
elderRows,
|
||||
DEFAULT_DEVICE_ASSETS.filter((device) => device.code !== params.missingDeviceCode).map((device) => ({ id: `device-${device.code}`, code: device.code })),
|
||||
DEFAULT_MAINTENANCE_TICKETS.filter((ticket) => ticket.title !== params.missingTicketTitle).map((ticket) => ({ title: ticket.title })),
|
||||
DEFAULT_NOTICES.filter((notice) => notice.title !== params.missingNoticeTitle).map((notice) => ({ title: notice.title })),
|
||||
DEFAULT_ALERT_RULES.filter((rule) => rule.name !== params.missingAlertRuleName).map((rule) => ({ id: `rule-${rule.name}`, name: rule.name })),
|
||||
DEFAULT_ALERT_TRIGGERS.filter((trigger) => trigger.title !== params.missingAlertTriggerTitle).map((trigger) => ({ title: trigger.title })),
|
||||
allFamilyContactRows.filter((contact) => contact.name !== params.missingFamilyContactName),
|
||||
DEFAULT_FAMILY_VISITS.filter((visit) => visit.notes !== params.missingFamilyVisitNotes).map((visit) => ({
|
||||
elderId: getElderId(visit.elderName),
|
||||
contactId: getContactId(visit.elderName, visit.contactName),
|
||||
notes: visit.notes,
|
||||
})),
|
||||
DEFAULT_FAMILY_FEEDBACK.filter((feedback) => feedback.content !== params.missingFamilyFeedbackContent).map((feedback) => ({
|
||||
elderId: getElderId(feedback.elderName),
|
||||
contactId: getContactId(feedback.elderName, feedback.contactName),
|
||||
content: feedback.content,
|
||||
})),
|
||||
];
|
||||
let selectIndex = 0;
|
||||
const insertCalls: Array<{ values: SeedInsertRow[] }> = [];
|
||||
const transaction = {
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn((values: unknown) => {
|
||||
const rows = toSeedInsertRows(values);
|
||||
insertCalls.push({ values: rows });
|
||||
|
||||
return {
|
||||
returning: vi.fn(() => rows.map((row, index) => ({ ...row, id: `inserted-${index}` }))),
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
const database = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => {
|
||||
const result = selectResults[selectIndex] ?? [];
|
||||
selectIndex += 1;
|
||||
return result;
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
transaction: vi.fn(async (callback: (transactionClient: typeof transaction) => Promise<void> | void) => {
|
||||
await callback(transaction);
|
||||
}),
|
||||
};
|
||||
|
||||
return { database: database as unknown as AppDatabase, insertCalls };
|
||||
}
|
||||
|
||||
describe("default collaboration seed data", () => {
|
||||
it("keeps idempotent seed natural keys stable, populated, and unique per collaboration table", () => {
|
||||
expectStableUniqueNaturalKeys(DEFAULT_DEVICE_ASSETS, (device) => device.code, [
|
||||
"DEV-CALL-A102-2",
|
||||
"DEV-SPO2-S101",
|
||||
"DEV-OXY-A302-2",
|
||||
"DEV-REHAB-R101",
|
||||
]);
|
||||
expectStableUniqueNaturalKeys(DEFAULT_MAINTENANCE_TICKETS, (ticket) => ticket.title, [
|
||||
"床头呼叫器按键失灵",
|
||||
"氧气接口保养延期",
|
||||
"康复步行带日常校准",
|
||||
]);
|
||||
expectStableUniqueNaturalKeys(DEFAULT_NOTICES, (notice) => notice.title, [
|
||||
"本周家属探访安排",
|
||||
"夜间重点巡房提醒",
|
||||
"设备巡检培训材料",
|
||||
]);
|
||||
expectStableUniqueNaturalKeys(DEFAULT_ALERT_RULES, (rule) => rule.name, [
|
||||
"血氧低值连续告警",
|
||||
"高优先级护理任务逾期",
|
||||
"设备维修工单未派单",
|
||||
]);
|
||||
expectStableUniqueNaturalKeys(DEFAULT_ALERT_TRIGGERS, (trigger) => trigger.title, [
|
||||
"S101 血氧低值需复核",
|
||||
"重点照护夜间巡检逾期",
|
||||
"氧气接口保养待派单",
|
||||
]);
|
||||
expectStableUniqueNaturalKeys(DEFAULT_FAMILY_CONTACTS, (contact) => `${contact.elderName}/${contact.name}`, [
|
||||
"王桂兰/张敏",
|
||||
"钱松柏/钱宁",
|
||||
"林玉琴/林晓",
|
||||
"赵国华/赵蕾",
|
||||
]);
|
||||
expectStableUniqueNaturalKeys(DEFAULT_FAMILY_VISITS, (visit) => `${visit.elderName}/${visit.contactName}/${visit.notes}`, [
|
||||
"王桂兰/张敏/安排护理楼一层会客区。",
|
||||
"钱松柏/钱宁/需医生确认夜间血氧情况后再回复。",
|
||||
"林玉琴/林晓/已完成探访和短住事项确认。",
|
||||
]);
|
||||
expectStableUniqueNaturalKeys(DEFAULT_FAMILY_FEEDBACK, (feedback) => `${feedback.elderName}/${feedback.contactName}/${feedback.content}`, [
|
||||
"赵国华/赵蕾/希望夜间巡房后能同步重点观察结果。",
|
||||
"林玉琴/林晓/短住房间清洁和呼叫器说明比较清楚。",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps default collaboration records resolvable through their natural-key dependencies", () => {
|
||||
const deviceCodes = new Set(DEFAULT_DEVICE_ASSETS.map((device) => device.code));
|
||||
const alertRuleNames = new Set(DEFAULT_ALERT_RULES.map((rule) => rule.name));
|
||||
const elderNames = new Set(DEFAULT_FAMILY_CONTACTS.map((contact) => contact.elderName));
|
||||
const contactKeys = new Set(DEFAULT_FAMILY_CONTACTS.map((contact) => `${contact.elderName}/${contact.name}`));
|
||||
|
||||
expect(DEFAULT_DEVICE_ASSETS.length).toBeGreaterThanOrEqual(3);
|
||||
expect(DEFAULT_MAINTENANCE_TICKETS.length).toBeGreaterThanOrEqual(1);
|
||||
expect(DEFAULT_NOTICES.length).toBeGreaterThanOrEqual(1);
|
||||
expect(DEFAULT_ALERT_RULES.length).toBeGreaterThanOrEqual(1);
|
||||
expect(DEFAULT_ALERT_TRIGGERS.length).toBeGreaterThanOrEqual(1);
|
||||
expect(DEFAULT_FAMILY_CONTACTS.length).toBeGreaterThanOrEqual(1);
|
||||
expect(DEFAULT_FAMILY_VISITS.length).toBeGreaterThanOrEqual(1);
|
||||
expect(DEFAULT_FAMILY_FEEDBACK.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
for (const ticket of DEFAULT_MAINTENANCE_TICKETS) {
|
||||
expect(deviceCodes.has(ticket.deviceCode)).toBe(true);
|
||||
}
|
||||
|
||||
for (const trigger of DEFAULT_ALERT_TRIGGERS) {
|
||||
expect(alertRuleNames.has(trigger.ruleName)).toBe(true);
|
||||
if (trigger.elderName) {
|
||||
expect(elderNames.has(trigger.elderName)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
for (const visit of DEFAULT_FAMILY_VISITS) {
|
||||
expect(contactKeys.has(`${visit.elderName}/${visit.contactName}`)).toBe(true);
|
||||
}
|
||||
|
||||
for (const feedback of DEFAULT_FAMILY_FEEDBACK) {
|
||||
expect(contactKeys.has(`${feedback.elderName}/${feedback.contactName}`)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps user-visible collaboration defaults realistic", () => {
|
||||
const userVisibleContent = [
|
||||
...DEFAULT_DEVICE_ASSETS.map((device) => [device.name, device.category, device.location, device.notes].join("\n")),
|
||||
...DEFAULT_MAINTENANCE_TICKETS.map((ticket) => [ticket.title, ticket.description, ticket.assigneeLabel, ticket.resolutionNotes].join("\n")),
|
||||
...DEFAULT_NOTICES.map((notice) => [notice.title, notice.content, notice.audience].join("\n")),
|
||||
...DEFAULT_ALERT_RULES.map((rule) => [rule.name, rule.ruleType, rule.conditionSummary, rule.suggestion].join("\n")),
|
||||
...DEFAULT_ALERT_TRIGGERS.map((trigger) => [trigger.title, trigger.description, trigger.source, trigger.handlingNotes].join("\n")),
|
||||
...DEFAULT_FAMILY_CONTACTS.map((contact) => [contact.elderName, contact.name, contact.relationship, contact.phone, contact.notes].join("\n")),
|
||||
...DEFAULT_FAMILY_VISITS.map((visit) => [visit.elderName, visit.contactName, visit.status, visit.notes].join("\n")),
|
||||
...DEFAULT_FAMILY_FEEDBACK.map((feedback) => [feedback.elderName, feedback.contactName, feedback.feedbackType, feedback.content, feedback.responseNotes].join("\n")),
|
||||
].join("\n");
|
||||
|
||||
expect(userVisibleContent).not.toMatch(FORBIDDEN_DEMO_WORDING_PATTERN);
|
||||
});
|
||||
|
||||
it("inserts only collaboration defaults missing from existing natural-key sets", async () => {
|
||||
const missingDevice = DEFAULT_DEVICE_ASSETS.find((device) => device.code === "DEV-CALL-A102-2");
|
||||
const missingTicket = DEFAULT_MAINTENANCE_TICKETS.find((ticket) => ticket.title === "床头呼叫器按键失灵");
|
||||
const missingNotice = DEFAULT_NOTICES.find((notice) => notice.title === "设备巡检培训材料");
|
||||
const missingAlertRule = DEFAULT_ALERT_RULES.find((rule) => rule.name === "血氧低值连续告警");
|
||||
const missingAlertTrigger = DEFAULT_ALERT_TRIGGERS.find((trigger) => trigger.title === "S101 血氧低值需复核");
|
||||
const missingFamilyContact = DEFAULT_FAMILY_CONTACTS.find((contact) => contact.name === "张敏");
|
||||
const missingFamilyVisit = DEFAULT_FAMILY_VISITS.find((visit) => visit.notes === "安排护理楼一层会客区。");
|
||||
const missingFamilyFeedback = DEFAULT_FAMILY_FEEDBACK.find((feedback) => feedback.content === "希望夜间巡房后能同步重点观察结果。");
|
||||
if (!missingDevice || !missingTicket || !missingNotice || !missingAlertRule || !missingAlertTrigger || !missingFamilyContact || !missingFamilyVisit || !missingFamilyFeedback) {
|
||||
throw new Error("Missing collaboration seed fixture for behavior test");
|
||||
}
|
||||
|
||||
const { database, insertCalls } = createCollaborationSeedDatabaseMock({
|
||||
missingDeviceCode: missingDevice.code,
|
||||
missingTicketTitle: missingTicket.title,
|
||||
missingNoticeTitle: missingNotice.title,
|
||||
missingAlertRuleName: missingAlertRule.name,
|
||||
missingAlertTriggerTitle: missingAlertTrigger.title,
|
||||
missingFamilyContactName: missingFamilyContact.name,
|
||||
missingFamilyVisitNotes: missingFamilyVisit.notes,
|
||||
missingFamilyFeedbackContent: missingFamilyFeedback.content,
|
||||
});
|
||||
vi.mocked(getDatabase).mockReturnValue(database);
|
||||
|
||||
await seedDefaultCollaborationWorkspaceData("organization-1");
|
||||
|
||||
const insertedRows = insertCalls.flatMap((call) => call.values);
|
||||
expect(insertedRows).toHaveLength(8);
|
||||
expect(rowsWithKey(insertedRows, "code")).toEqual([expect.objectContaining({ code: missingDevice.code })]);
|
||||
expect(rowsWithKey(insertedRows, "priority")).toEqual([expect.objectContaining({ title: missingTicket.title })]);
|
||||
expect(rowsWithKey(insertedRows, "audience")).toEqual([expect.objectContaining({ title: missingNotice.title })]);
|
||||
expect(rowsWithKey(insertedRows, "conditionSummary")).toEqual([expect.objectContaining({ name: missingAlertRule.name })]);
|
||||
expect(rowsWithKey(insertedRows, "source")).toEqual([expect.objectContaining({ title: missingAlertTrigger.title })]);
|
||||
expect(rowsWithKey(insertedRows, "relationship")).toEqual([expect.objectContaining({ name: missingFamilyContact.name })]);
|
||||
expect(rowsWithKey(insertedRows, "scheduledAt")).toEqual([expect.objectContaining({ notes: missingFamilyVisit.notes })]);
|
||||
expect(rowsWithKey(insertedRows, "feedbackType")).toEqual([expect.objectContaining({ content: missingFamilyFeedback.content })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default AI workspace seed data", () => {
|
||||
it("keeps user-visible AI knowledge content realistic and validator-ready", () => {
|
||||
expect(DEFAULT_AI_KNOWLEDGE_ENTRIES.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
@@ -979,7 +979,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
|
||||
export const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
|
||||
{
|
||||
name: "A102 床头呼叫器",
|
||||
code: "DEV-CALL-A102-2",
|
||||
@@ -1018,7 +1018,7 @@ const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
|
||||
export const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
|
||||
{
|
||||
deviceCode: "DEV-CALL-A102-2",
|
||||
title: "床头呼叫器按键失灵",
|
||||
@@ -1048,7 +1048,7 @@ const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_NOTICES: SeedNotice[] = [
|
||||
export const DEFAULT_NOTICES: SeedNotice[] = [
|
||||
{
|
||||
title: "本周家属探访安排",
|
||||
content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。",
|
||||
@@ -1071,7 +1071,7 @@ const DEFAULT_NOTICES: SeedNotice[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
|
||||
export const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
|
||||
{
|
||||
name: "血氧低值连续告警",
|
||||
ruleType: "health",
|
||||
@@ -1098,7 +1098,7 @@ const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
|
||||
export const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
|
||||
{
|
||||
ruleName: "血氧低值连续告警",
|
||||
elderName: "钱松柏",
|
||||
@@ -1130,20 +1130,20 @@ const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_FAMILY_CONTACTS: SeedFamilyContact[] = [
|
||||
export const DEFAULT_FAMILY_CONTACTS: SeedFamilyContact[] = [
|
||||
{ elderName: "王桂兰", name: "张敏", relationship: "女儿", phone: "13800000001", status: "active", notes: "主要联系人,每周六探访。" },
|
||||
{ elderName: "钱松柏", name: "钱宁", relationship: "儿子", phone: "13800000011", status: "active", notes: "关注夜间吸氧情况。" },
|
||||
{ elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", status: "active", notes: "短住托养资料已确认。" },
|
||||
{ elderName: "赵国华", name: "赵蕾", relationship: "孙女", phone: "13800000016", status: "active", notes: "希望接收护理反馈。" },
|
||||
];
|
||||
|
||||
const DEFAULT_FAMILY_VISITS: SeedFamilyVisit[] = [
|
||||
export const DEFAULT_FAMILY_VISITS: SeedFamilyVisit[] = [
|
||||
{ elderName: "王桂兰", contactName: "张敏", scheduledHoursOffset: 30, status: "approved", notes: "安排护理楼一层会客区。" },
|
||||
{ elderName: "钱松柏", contactName: "钱宁", scheduledHoursOffset: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" },
|
||||
{ elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" },
|
||||
];
|
||||
|
||||
const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
|
||||
export const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
|
||||
{
|
||||
elderName: "赵国华",
|
||||
contactName: "赵蕾",
|
||||
@@ -1510,6 +1510,271 @@ async function seedDefaultAiWorkspaceData(organizationId: string): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
function getFamilyContactKey(elderId: string, contactName: string): string {
|
||||
return `${elderId}:${contactName}`;
|
||||
}
|
||||
|
||||
function getFamilyVisitKey(elderId: string, contactId: string, notes: string): string {
|
||||
return `${elderId}:${contactId}:${notes}`;
|
||||
}
|
||||
|
||||
function getFamilyFeedbackKey(elderId: string, contactId: string, content: string): string {
|
||||
return `${elderId}:${contactId}:${content}`;
|
||||
}
|
||||
|
||||
export async function seedDefaultCollaborationWorkspaceData(organizationId: string): Promise<void> {
|
||||
const database = getDatabase();
|
||||
const [
|
||||
elderRows,
|
||||
existingDeviceRows,
|
||||
existingTicketRows,
|
||||
existingNoticeRows,
|
||||
existingAlertRuleRows,
|
||||
existingAlertTriggerRows,
|
||||
existingFamilyContactRows,
|
||||
existingFamilyVisitRows,
|
||||
existingFamilyFeedbackRows,
|
||||
] = await Promise.all([
|
||||
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
|
||||
database.select().from(deviceAssets).where(eq(deviceAssets.organizationId, organizationId)),
|
||||
database.select().from(maintenanceTickets).where(eq(maintenanceTickets.organizationId, organizationId)),
|
||||
database.select().from(notices).where(eq(notices.organizationId, organizationId)),
|
||||
database.select().from(alertRules).where(eq(alertRules.organizationId, organizationId)),
|
||||
database.select().from(alertTriggers).where(eq(alertTriggers.organizationId, organizationId)),
|
||||
database.select().from(familyContacts).where(eq(familyContacts.organizationId, organizationId)),
|
||||
database.select().from(familyVisitAppointments).where(eq(familyVisitAppointments.organizationId, organizationId)),
|
||||
database.select().from(familyFeedback).where(eq(familyFeedback.organizationId, organizationId)),
|
||||
]);
|
||||
|
||||
const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id]));
|
||||
const existingDeviceCodes = new Set(existingDeviceRows.map((device) => device.code));
|
||||
const existingTicketTitles = new Set(existingTicketRows.map((ticket) => ticket.title));
|
||||
const existingNoticeTitles = new Set(existingNoticeRows.map((notice) => notice.title));
|
||||
const existingAlertRuleNames = new Set(existingAlertRuleRows.map((rule) => rule.name));
|
||||
const existingAlertTriggerTitles = new Set(existingAlertTriggerRows.map((trigger) => trigger.title));
|
||||
const existingFamilyContactKeys = new Set(existingFamilyContactRows.map((contact) => getFamilyContactKey(contact.elderId, contact.name)));
|
||||
const existingFamilyVisitKeys = new Set(
|
||||
existingFamilyVisitRows
|
||||
.map((visit) => (visit.contactId ? getFamilyVisitKey(visit.elderId, visit.contactId, visit.notes) : ""))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const existingFamilyFeedbackKeys = new Set(
|
||||
existingFamilyFeedbackRows
|
||||
.map((feedback) => (feedback.contactId ? getFamilyFeedbackKey(feedback.elderId, feedback.contactId, feedback.content) : ""))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const getContactId = (contactRows: typeof existingFamilyContactRows, elderId: string, contactName: string): string | undefined =>
|
||||
contactRows.find((contact) => contact.elderId === elderId && contact.name === contactName)?.id;
|
||||
const getMissingFamilyVisits = (contactRows: typeof existingFamilyContactRows): SeedFamilyVisit[] =>
|
||||
DEFAULT_FAMILY_VISITS.filter((visit) => {
|
||||
const elderId = elderIdByName.get(visit.elderName);
|
||||
const contactId = elderId ? getContactId(contactRows, elderId, visit.contactName) : undefined;
|
||||
return Boolean(elderId && contactId && !existingFamilyVisitKeys.has(getFamilyVisitKey(elderId, contactId, visit.notes)));
|
||||
});
|
||||
const getMissingFamilyFeedback = (contactRows: typeof existingFamilyContactRows): SeedFamilyFeedback[] =>
|
||||
DEFAULT_FAMILY_FEEDBACK.filter((feedback) => {
|
||||
const elderId = elderIdByName.get(feedback.elderName);
|
||||
const contactId = elderId ? getContactId(contactRows, elderId, feedback.contactName) : undefined;
|
||||
return Boolean(elderId && contactId && !existingFamilyFeedbackKeys.has(getFamilyFeedbackKey(elderId, contactId, feedback.content)));
|
||||
});
|
||||
|
||||
const missingDevices = DEFAULT_DEVICE_ASSETS.filter((device) => !existingDeviceCodes.has(device.code));
|
||||
const missingTickets = DEFAULT_MAINTENANCE_TICKETS.filter((ticket) => !existingTicketTitles.has(ticket.title));
|
||||
const missingNotices = DEFAULT_NOTICES.filter((notice) => !existingNoticeTitles.has(notice.title));
|
||||
const missingAlertRules = DEFAULT_ALERT_RULES.filter((rule) => !existingAlertRuleNames.has(rule.name));
|
||||
const missingAlertTriggers = DEFAULT_ALERT_TRIGGERS.filter((trigger) => !existingAlertTriggerTitles.has(trigger.title));
|
||||
const missingFamilyContacts = DEFAULT_FAMILY_CONTACTS.filter((contact) => {
|
||||
const elderId = elderIdByName.get(contact.elderName);
|
||||
return Boolean(elderId && !existingFamilyContactKeys.has(getFamilyContactKey(elderId, contact.name)));
|
||||
});
|
||||
const missingFamilyVisits = getMissingFamilyVisits(existingFamilyContactRows);
|
||||
const missingFamilyFeedback = getMissingFamilyFeedback(existingFamilyContactRows);
|
||||
|
||||
if (
|
||||
missingDevices.length === 0 &&
|
||||
missingTickets.length === 0 &&
|
||||
missingNotices.length === 0 &&
|
||||
missingAlertRules.length === 0 &&
|
||||
missingAlertTriggers.length === 0 &&
|
||||
missingFamilyContacts.length === 0 &&
|
||||
missingFamilyVisits.length === 0 &&
|
||||
missingFamilyFeedback.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await database.transaction(async (transaction) => {
|
||||
const insertedDeviceRows = missingDevices.length > 0
|
||||
? await transaction
|
||||
.insert(deviceAssets)
|
||||
.values(
|
||||
missingDevices.map((device) => ({
|
||||
organizationId,
|
||||
name: device.name,
|
||||
code: device.code,
|
||||
category: device.category,
|
||||
location: device.location,
|
||||
status: device.status,
|
||||
lastInspectedAt: device.lastInspectedHoursAgo === undefined ? undefined : new Date(now.getTime() - device.lastInspectedHoursAgo * 60 * 60 * 1000),
|
||||
notes: device.notes,
|
||||
})),
|
||||
)
|
||||
.returning()
|
||||
: [];
|
||||
if (insertedDeviceRows.length !== missingDevices.length) {
|
||||
throw new Error("默认设备台账初始化失败");
|
||||
}
|
||||
const deviceIdByCode = new Map([...existingDeviceRows, ...insertedDeviceRows].map((device) => [device.code, device.id]));
|
||||
|
||||
if (missingTickets.length > 0) {
|
||||
const ticketValues = missingTickets.map((ticket) => {
|
||||
const deviceId = deviceIdByCode.get(ticket.deviceCode);
|
||||
if (!deviceId) {
|
||||
throw new Error("默认维修工单初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
deviceId,
|
||||
title: ticket.title,
|
||||
description: ticket.description,
|
||||
priority: ticket.priority,
|
||||
status: ticket.status,
|
||||
assigneeLabel: ticket.assigneeLabel,
|
||||
resolutionNotes: ticket.resolutionNotes,
|
||||
resolvedAt: ticket.status === "resolved" || ticket.status === "closed" ? now : undefined,
|
||||
};
|
||||
});
|
||||
await transaction.insert(maintenanceTickets).values(ticketValues);
|
||||
}
|
||||
|
||||
if (missingNotices.length > 0) {
|
||||
await transaction.insert(notices).values(
|
||||
missingNotices.map((notice) => ({
|
||||
organizationId,
|
||||
title: notice.title,
|
||||
content: notice.content,
|
||||
audience: notice.audience,
|
||||
status: notice.status,
|
||||
publishedAt: notice.publishedHoursAgo === undefined ? undefined : new Date(now.getTime() - notice.publishedHoursAgo * 60 * 60 * 1000),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const insertedAlertRuleRows = missingAlertRules.length > 0
|
||||
? await transaction.insert(alertRules).values(missingAlertRules.map((rule) => ({ ...rule, organizationId }))).returning()
|
||||
: [];
|
||||
if (insertedAlertRuleRows.length !== missingAlertRules.length) {
|
||||
throw new Error("默认预警规则初始化失败");
|
||||
}
|
||||
const alertRuleIdByName = new Map([...existingAlertRuleRows, ...insertedAlertRuleRows].map((rule) => [rule.name, rule.id]));
|
||||
|
||||
const alertTriggerValues = missingAlertTriggers
|
||||
.map((trigger) => {
|
||||
const ruleId = alertRuleIdByName.get(trigger.ruleName);
|
||||
const elderId = trigger.elderName ? elderIdByName.get(trigger.elderName) : undefined;
|
||||
if (!ruleId || (trigger.elderName && !elderId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
ruleId,
|
||||
elderId,
|
||||
title: trigger.title,
|
||||
description: trigger.description,
|
||||
status: trigger.status,
|
||||
source: trigger.source,
|
||||
handlingNotes: trigger.handlingNotes,
|
||||
handledAt: trigger.status === "open" ? undefined : now,
|
||||
createdAt: new Date(now.getTime() - trigger.createdHoursAgo * 60 * 60 * 1000),
|
||||
updatedAt: now,
|
||||
};
|
||||
})
|
||||
.filter((value): value is Exclude<typeof value, null> => value !== null);
|
||||
if (alertTriggerValues.length > 0) {
|
||||
await transaction.insert(alertTriggers).values(alertTriggerValues);
|
||||
}
|
||||
|
||||
const insertedFamilyContactRows = missingFamilyContacts.length > 0
|
||||
? await transaction
|
||||
.insert(familyContacts)
|
||||
.values(
|
||||
missingFamilyContacts.map((contact) => {
|
||||
const elderId = elderIdByName.get(contact.elderName);
|
||||
if (!elderId) {
|
||||
throw new Error("默认家属联系人初始化失败");
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
elderId,
|
||||
name: contact.name,
|
||||
relationship: contact.relationship,
|
||||
phone: contact.phone,
|
||||
status: contact.status,
|
||||
notes: contact.notes,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.returning()
|
||||
: [];
|
||||
if (insertedFamilyContactRows.length !== missingFamilyContacts.length) {
|
||||
throw new Error("默认家属联系人初始化失败");
|
||||
}
|
||||
const allFamilyContactRows = [...existingFamilyContactRows, ...insertedFamilyContactRows];
|
||||
|
||||
const familyVisitValues = getMissingFamilyVisits(allFamilyContactRows)
|
||||
.map((visit) => {
|
||||
const elderId = elderIdByName.get(visit.elderName);
|
||||
const contactId = elderId ? getContactId(allFamilyContactRows, elderId, visit.contactName) : undefined;
|
||||
if (!elderId || !contactId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
elderId,
|
||||
contactId,
|
||||
scheduledAt: new Date(now.getTime() + visit.scheduledHoursOffset * 60 * 60 * 1000),
|
||||
status: visit.status,
|
||||
notes: visit.notes,
|
||||
handledAt: visit.status === "requested" ? undefined : now,
|
||||
};
|
||||
})
|
||||
.filter((value): value is Exclude<typeof value, null> => value !== null);
|
||||
if (familyVisitValues.length > 0) {
|
||||
await transaction.insert(familyVisitAppointments).values(familyVisitValues);
|
||||
}
|
||||
|
||||
const familyFeedbackValues = getMissingFamilyFeedback(allFamilyContactRows)
|
||||
.map((feedback) => {
|
||||
const elderId = elderIdByName.get(feedback.elderName);
|
||||
const contactId = elderId ? getContactId(allFamilyContactRows, elderId, feedback.contactName) : undefined;
|
||||
if (!elderId || !contactId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
elderId,
|
||||
contactId,
|
||||
feedbackType: feedback.feedbackType,
|
||||
content: feedback.content,
|
||||
status: feedback.status,
|
||||
responseNotes: feedback.responseNotes,
|
||||
handledAt: feedback.status === "open" ? undefined : now,
|
||||
};
|
||||
})
|
||||
.filter((value): value is Exclude<typeof value, null> => value !== null);
|
||||
if (familyFeedbackValues.length > 0) {
|
||||
await transaction.insert(familyFeedback).values(familyFeedbackValues);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function hasRows(rows: unknown[]): boolean {
|
||||
return rows.length > 0;
|
||||
}
|
||||
@@ -1524,6 +1789,7 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
|
||||
]);
|
||||
|
||||
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
|
||||
await seedDefaultCollaborationWorkspaceData(organizationId);
|
||||
await seedDefaultAiWorkspaceData(organizationId);
|
||||
return;
|
||||
}
|
||||
@@ -1983,5 +2249,6 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
|
||||
}),
|
||||
);
|
||||
});
|
||||
await seedDefaultCollaborationWorkspaceData(organizationId);
|
||||
await seedDefaultAiWorkspaceData(organizationId);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import Link from "next/link";
|
||||
import { Activity, AlertTriangle, BedDouble, CheckCircle2, HeartPulse, ListChecks, Users } from "lucide-react";
|
||||
import { Activity, AlertTriangle, BedDouble, BrainCircuit, CheckCircle2, HeartPulse, ListChecks, Megaphone, ShieldAlert, TabletSmartphone, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table } from "@/components/ui/table";
|
||||
import type { ElderAiAnalysisBoardItem, ElderAiRiskLevel } from "@/modules/ai/types";
|
||||
import type { AlertTriggerStatus } from "@/modules/alerts/types";
|
||||
import { ALERT_TRIGGER_STATUS_LABELS } from "@/modules/alerts/types";
|
||||
import type { AdmissionStatus, BedStatus, IncidentSeverity } from "@/modules/core/types";
|
||||
import type { CareTaskPriority, CareTaskStatus } from "@/modules/care/types";
|
||||
import { CARE_TASK_PRIORITY_LABELS, CARE_TASK_STATUS_LABELS } from "@/modules/care/types";
|
||||
import { BedOccupancyChart, type BedOccupancyDatum } from "@/modules/dashboard/components/BedOccupancyChart";
|
||||
import type { MaintenanceTicketPriority, MaintenanceTicketStatus } from "@/modules/devices/types";
|
||||
import { MAINTENANCE_TICKET_PRIORITY_LABELS, MAINTENANCE_TICKET_STATUS_LABELS } from "@/modules/devices/types";
|
||||
import { INCIDENT_SEVERITY_LABELS, INCIDENT_STATUS_LABELS } from "@/modules/emergency/types";
|
||||
import type { FamilyFeedbackStatus, FamilyVisitStatus } from "@/modules/family/types";
|
||||
import { FAMILY_FEEDBACK_STATUS_LABELS, FAMILY_VISIT_STATUS_LABELS } from "@/modules/family/types";
|
||||
import type { HealthReviewSeverity, HealthReviewStatus } from "@/modules/health/types";
|
||||
import { HEALTH_REVIEW_SEVERITY_LABELS, HEALTH_REVIEW_STATUS_LABELS } from "@/modules/health/types";
|
||||
|
||||
import type { NoticeStatus } from "@/modules/notices/types";
|
||||
import { NOTICE_STATUS_LABELS } from "@/modules/notices/types";
|
||||
export type DashboardMetric = {
|
||||
label: string;
|
||||
value: string;
|
||||
trend: string;
|
||||
icon: "admissions" | "beds" | "care" | "health" | "incidents" | "users";
|
||||
icon: "admissions" | "ai" | "alerts" | "beds" | "care" | "devices" | "family" | "health" | "incidents" | "notices" | "users";
|
||||
};
|
||||
|
||||
export type DashboardAdmission = {
|
||||
@@ -56,26 +64,82 @@ export type DashboardHealthReview = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type DashboardDeviceTicket = {
|
||||
id: string;
|
||||
deviceName: string;
|
||||
priority: MaintenanceTicketPriority;
|
||||
status: MaintenanceTicketStatus;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DashboardNotice = {
|
||||
id: string;
|
||||
audience: string;
|
||||
status: NoticeStatus;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DashboardAlertTrigger = {
|
||||
id: string;
|
||||
elderName: string;
|
||||
status: AlertTriggerStatus;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DashboardFamilyVisit = {
|
||||
id: string;
|
||||
contactName: string;
|
||||
elderName: string;
|
||||
scheduledAt: string;
|
||||
status: FamilyVisitStatus;
|
||||
};
|
||||
|
||||
export type DashboardFamilyFeedback = {
|
||||
id: string;
|
||||
elderName: string;
|
||||
status: FamilyFeedbackStatus;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type DashboardHomeProps = {
|
||||
alertTriggers?: DashboardAlertTrigger[];
|
||||
alertsHref: string;
|
||||
aiBoardItems?: ElderAiAnalysisBoardItem[];
|
||||
bedsHref: string;
|
||||
careHref: string;
|
||||
careTasks: DashboardCareTask[];
|
||||
careTasks?: DashboardCareTask[];
|
||||
deviceTickets?: DashboardDeviceTicket[];
|
||||
devicesHref: string;
|
||||
eldersHref: string;
|
||||
emergencyHref: string;
|
||||
emergencyIncidents: DashboardIncident[];
|
||||
emergencyIncidents?: DashboardIncident[];
|
||||
familyFeedback?: DashboardFamilyFeedback[];
|
||||
familyHref: string;
|
||||
familyVisits?: DashboardFamilyVisit[];
|
||||
healthHref: string;
|
||||
healthReviews: DashboardHealthReview[];
|
||||
healthReviews?: DashboardHealthReview[];
|
||||
metrics: DashboardMetric[];
|
||||
occupancyData: BedOccupancyDatum[];
|
||||
recentAdmissions: DashboardAdmission[];
|
||||
incidents: DashboardIncident[];
|
||||
notices?: DashboardNotice[];
|
||||
noticesHref: string;
|
||||
occupancyData?: BedOccupancyDatum[];
|
||||
recentAdmissions?: DashboardAdmission[];
|
||||
incidents?: DashboardIncident[];
|
||||
};
|
||||
|
||||
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
|
||||
admissions: CheckCircle2,
|
||||
ai: BrainCircuit,
|
||||
alerts: ShieldAlert,
|
||||
beds: BedDouble,
|
||||
care: ListChecks,
|
||||
devices: TabletSmartphone,
|
||||
family: Users,
|
||||
health: HeartPulse,
|
||||
incidents: HeartPulse,
|
||||
notices: Megaphone,
|
||||
users: Users,
|
||||
};
|
||||
|
||||
@@ -107,19 +171,72 @@ function incidentVariant(severity: IncidentSeverity): "secondary" | "warning" |
|
||||
}
|
||||
}
|
||||
|
||||
const analysisRiskLabels: Record<ElderAiRiskLevel, string> = {
|
||||
critical: "高危",
|
||||
high: "高风险",
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
unknown: "未知",
|
||||
};
|
||||
|
||||
function analysisRiskVariant(risk: ElderAiRiskLevel): "danger" | "secondary" | "success" | "warning" {
|
||||
switch (risk) {
|
||||
case "critical":
|
||||
return "danger";
|
||||
case "high":
|
||||
case "medium":
|
||||
return "warning";
|
||||
case "low":
|
||||
return "success";
|
||||
case "unknown":
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardHome({
|
||||
alertTriggers,
|
||||
alertsHref,
|
||||
aiBoardItems,
|
||||
bedsHref,
|
||||
careHref,
|
||||
careTasks,
|
||||
deviceTickets,
|
||||
devicesHref,
|
||||
eldersHref,
|
||||
emergencyHref,
|
||||
emergencyIncidents,
|
||||
familyFeedback,
|
||||
familyHref,
|
||||
familyVisits,
|
||||
healthHref,
|
||||
healthReviews,
|
||||
metrics,
|
||||
notices,
|
||||
noticesHref,
|
||||
occupancyData,
|
||||
recentAdmissions,
|
||||
incidents,
|
||||
}: DashboardHomeProps): React.ReactElement {
|
||||
const familyQueueItems: QueueItem[] = [
|
||||
...(familyVisits ?? []).map((visit) => ({
|
||||
id: `visit-${visit.id}`,
|
||||
title: `${visit.elderName} 探访`,
|
||||
detail: `${visit.contactName} / ${formatDateTime(visit.scheduledAt)}`,
|
||||
badge: FAMILY_VISIT_STATUS_LABELS[visit.status],
|
||||
variant: visit.status === "requested" ? "warning" as const : "secondary" as const,
|
||||
meta: "探访",
|
||||
})),
|
||||
...(familyFeedback ?? []).map((feedback) => ({
|
||||
id: `feedback-${feedback.id}`,
|
||||
title: `${feedback.elderName} 家属反馈`,
|
||||
detail: formatDateTime(feedback.updatedAt),
|
||||
badge: FAMILY_FEEDBACK_STATUS_LABELS[feedback.status],
|
||||
variant: feedback.status === "open" || feedback.status === "in_progress" ? "warning" as const : "secondary" as const,
|
||||
meta: "反馈",
|
||||
})),
|
||||
].slice(0, 6);
|
||||
const hasCoreQueues = careTasks !== undefined || healthReviews !== undefined || emergencyIncidents !== undefined;
|
||||
const hasCollaborationQueues = deviceTickets !== undefined || notices !== undefined || alertTriggers !== undefined || familyVisits !== undefined || familyFeedback !== undefined;
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
@@ -148,132 +265,247 @@ export function DashboardHome({
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-3">
|
||||
<QueueCard
|
||||
emptyText="暂无待处理护理任务"
|
||||
href={careHref}
|
||||
items={careTasks.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
detail: `${task.elderName || "公共区域"} / ${task.assigneeLabel || "未分配"} / ${formatDateTime(task.scheduledAt)}`,
|
||||
badge: CARE_TASK_STATUS_LABELS[task.status],
|
||||
variant: task.priority === "urgent" ? "danger" : task.priority === "high" ? "warning" : "secondary",
|
||||
meta: CARE_TASK_PRIORITY_LABELS[task.priority],
|
||||
}))}
|
||||
title="护理待办"
|
||||
/>
|
||||
<QueueCard
|
||||
emptyText="暂无待复核健康异常"
|
||||
href={healthHref}
|
||||
items={healthReviews.map((review) => ({
|
||||
id: review.id,
|
||||
title: review.title,
|
||||
detail: review.elderName,
|
||||
badge: HEALTH_REVIEW_STATUS_LABELS[review.status],
|
||||
variant: review.severity === "critical" ? "danger" : "warning",
|
||||
meta: HEALTH_REVIEW_SEVERITY_LABELS[review.severity],
|
||||
}))}
|
||||
title="健康复核"
|
||||
/>
|
||||
<QueueCard
|
||||
emptyText="暂无待处理应急事件"
|
||||
href={emergencyHref}
|
||||
items={emergencyIncidents.map((incident) => ({
|
||||
id: incident.id,
|
||||
title: incident.title,
|
||||
detail: `${incident.source} / ${formatDateTime(incident.createdAt)}`,
|
||||
badge: INCIDENT_STATUS_LABELS[incident.status as keyof typeof INCIDENT_STATUS_LABELS],
|
||||
variant: incidentVariant(incident.severity),
|
||||
meta: INCIDENT_SEVERITY_LABELS[incident.severity],
|
||||
}))}
|
||||
title="安全应急"
|
||||
/>
|
||||
</section>
|
||||
{hasCoreQueues ? (
|
||||
<section className="grid gap-4 xl:grid-cols-3">
|
||||
{careTasks !== undefined ? (
|
||||
<QueueCard
|
||||
emptyText="暂无待处理护理任务"
|
||||
href={careHref}
|
||||
items={careTasks.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
detail: `${task.elderName || "公共区域"} / ${task.assigneeLabel || "未分配"} / ${formatDateTime(task.scheduledAt)}`,
|
||||
badge: CARE_TASK_STATUS_LABELS[task.status],
|
||||
variant: task.priority === "urgent" ? "danger" : task.priority === "high" ? "warning" : "secondary",
|
||||
meta: CARE_TASK_PRIORITY_LABELS[task.priority],
|
||||
}))}
|
||||
title="护理待办"
|
||||
/>
|
||||
) : null}
|
||||
{healthReviews !== undefined ? (
|
||||
<QueueCard
|
||||
emptyText="暂无待复核健康异常"
|
||||
href={healthHref}
|
||||
items={healthReviews.map((review) => ({
|
||||
id: review.id,
|
||||
title: review.title,
|
||||
detail: review.elderName,
|
||||
badge: HEALTH_REVIEW_STATUS_LABELS[review.status],
|
||||
variant: review.severity === "critical" ? "danger" : "warning",
|
||||
meta: HEALTH_REVIEW_SEVERITY_LABELS[review.severity],
|
||||
}))}
|
||||
title="健康复核"
|
||||
/>
|
||||
) : null}
|
||||
{emergencyIncidents !== undefined ? (
|
||||
<QueueCard
|
||||
emptyText="暂无待处理应急事件"
|
||||
href={emergencyHref}
|
||||
items={emergencyIncidents.map((incident) => ({
|
||||
id: incident.id,
|
||||
title: incident.title,
|
||||
detail: `${incident.source} / ${formatDateTime(incident.createdAt)}`,
|
||||
badge: INCIDENT_STATUS_LABELS[incident.status as keyof typeof INCIDENT_STATUS_LABELS],
|
||||
variant: incidentVariant(incident.severity),
|
||||
meta: INCIDENT_SEVERITY_LABELS[incident.severity],
|
||||
}))}
|
||||
title="安全应急"
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="size-5 text-primary" aria-hidden="true" />
|
||||
床位状态分布
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{occupancyData.map((item) => `${bedStatusLabels[item.label as BedStatus] ?? item.label} ${item.count}`).join(" / ")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link className="block" href={bedsHref}>
|
||||
<BedOccupancyChart data={occupancyData} />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{hasCollaborationQueues ? (
|
||||
<section className="grid gap-4 xl:grid-cols-4">
|
||||
{deviceTickets !== undefined ? (
|
||||
<QueueCard
|
||||
emptyText="暂无待处理维修工单"
|
||||
href={devicesHref}
|
||||
items={deviceTickets.map((ticket) => ({
|
||||
id: ticket.id,
|
||||
title: ticket.title,
|
||||
detail: `${ticket.deviceName} / ${formatDateTime(ticket.updatedAt)}`,
|
||||
badge: MAINTENANCE_TICKET_STATUS_LABELS[ticket.status],
|
||||
variant: ticket.priority === "urgent" ? "danger" : ticket.priority === "high" ? "warning" : "secondary",
|
||||
meta: MAINTENANCE_TICKET_PRIORITY_LABELS[ticket.priority],
|
||||
}))}
|
||||
title="设备工单"
|
||||
/>
|
||||
) : null}
|
||||
{notices !== undefined ? (
|
||||
<QueueCard
|
||||
emptyText="暂无公告"
|
||||
href={noticesHref}
|
||||
items={notices.map((notice) => ({
|
||||
id: notice.id,
|
||||
title: notice.title,
|
||||
detail: `${notice.audience} / ${formatDateTime(notice.updatedAt)}`,
|
||||
badge: NOTICE_STATUS_LABELS[notice.status],
|
||||
variant: notice.status === "published" ? "success" : "secondary",
|
||||
meta: "公告",
|
||||
}))}
|
||||
title="公告通知"
|
||||
/>
|
||||
) : null}
|
||||
{alertTriggers !== undefined ? (
|
||||
<QueueCard
|
||||
emptyText="暂无预警记录"
|
||||
href={alertsHref}
|
||||
items={alertTriggers.map((trigger) => ({
|
||||
id: trigger.id,
|
||||
title: trigger.title,
|
||||
detail: `${trigger.elderName || "公共区域"} / ${formatDateTime(trigger.updatedAt)}`,
|
||||
badge: ALERT_TRIGGER_STATUS_LABELS[trigger.status],
|
||||
variant: trigger.status === "open" ? "danger" : trigger.status === "acknowledged" ? "warning" : "secondary",
|
||||
meta: "预警",
|
||||
}))}
|
||||
title="规则预警"
|
||||
/>
|
||||
) : null}
|
||||
{familyVisits !== undefined || familyFeedback !== undefined ? (
|
||||
<QueueCard
|
||||
emptyText="暂无家属协同事项"
|
||||
href={familyHref}
|
||||
items={familyQueueItems}
|
||||
title="家属服务"
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最近入住记录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table className="w-full min-w-[620px] text-sm">
|
||||
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<Table.Row>
|
||||
<Table.Head className="px-4 py-3 font-medium">老人</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">床位</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">时间</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body className="divide-y bg-card">
|
||||
{recentAdmissions.map((admission) => (
|
||||
<Table.Row key={admission.id}>
|
||||
<Table.Cell className="px-4 py-3 font-medium">{admission.elderName}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{admission.bedLabel}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<Badge variant={admission.status === "active" ? "success" : "secondary"}>
|
||||
{admissionStatusLabels[admission.status]}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">
|
||||
{formatDateTime(admission.dischargedAt ?? admission.admittedAt)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
{recentAdmissions.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={4}>
|
||||
暂无入住记录
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : null}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
{occupancyData !== undefined || recentAdmissions !== undefined ? (
|
||||
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
{occupancyData !== undefined ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="size-5 text-primary" aria-hidden="true" />
|
||||
床位状态分布
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{occupancyData.map((item) => `${bedStatusLabels[item.label as BedStatus] ?? item.label} ${item.count}`).join(" / ")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link className="block" href={bedsHref}>
|
||||
<BedOccupancyChart data={occupancyData} />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>未关闭故障</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{incidents.map((incident) => (
|
||||
<div key={incident.id} className="flex items-start justify-between gap-3 rounded-md border p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 shrink-0 text-amber-600" aria-hidden="true" />
|
||||
<p className="truncate text-sm font-medium">{incident.title}</p>
|
||||
{recentAdmissions !== undefined ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最近入住记录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table className="w-full min-w-[620px] text-sm">
|
||||
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<Table.Row>
|
||||
<Table.Head className="px-4 py-3 font-medium">老人</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">床位</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">时间</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body className="divide-y bg-card">
|
||||
{recentAdmissions.map((admission) => (
|
||||
<Table.Row key={admission.id}>
|
||||
<Table.Cell className="px-4 py-3 font-medium">{admission.elderName}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{admission.bedLabel}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<Badge variant={admission.status === "active" ? "success" : "secondary"}>
|
||||
{admissionStatusLabels[admission.status]}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">
|
||||
{formatDateTime(admission.dischargedAt ?? admission.admittedAt)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
{recentAdmissions.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={4}>
|
||||
暂无入住记录
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : null}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{incident.source} / {formatDateTime(incident.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={incidentVariant(incident.severity)}>{incident.status}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{aiBoardItems !== undefined ? (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BrainCircuit className="size-5 text-primary" aria-hidden="true" />
|
||||
智能分析
|
||||
</CardTitle>
|
||||
<CardDescription>来自已保存老人 AI 分析历史,按数据权限自动脱敏。</CardDescription>
|
||||
</div>
|
||||
))}
|
||||
{incidents.length === 0 ? <p className="text-sm text-muted-foreground">暂无未关闭故障</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link className="text-sm font-medium text-primary hover:underline" href={eldersHref}>
|
||||
老人档案
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{aiBoardItems.map((item) => (
|
||||
<Link className="rounded-md border p-3 hover:bg-secondary/60" href={eldersHref} key={item.id}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{item.elderName}</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{item.restricted ? "结果受限:缺少一个或多个数据范围权限" : item.result?.summary ?? item.errorReason ?? "暂无摘要"}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{item.dataScopes.join(" / ")} / {formatDateTime(item.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
<Badge variant={item.status === "failed" ? "danger" : "success"}>{item.status === "failed" ? "失败" : "已完成"}</Badge>
|
||||
{!item.restricted && item.result ? (
|
||||
<Badge variant={analysisRiskVariant(item.result.overallRiskLevel)}>{analysisRiskLabels[item.result.overallRiskLevel]}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{aiBoardItems.length === 0 ? <p className="text-sm text-muted-foreground">暂无智能分析记录</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{incidents !== undefined ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>未关闭故障</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{incidents.map((incident) => (
|
||||
<div key={incident.id} className="flex items-start justify-between gap-3 rounded-md border p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 shrink-0 text-amber-600" aria-hidden="true" />
|
||||
<p className="truncate text-sm font-medium">{incident.title}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{incident.source} / {formatDateTime(incident.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={incidentVariant(incident.severity)}>{incident.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{incidents.length === 0 ? <p className="text-sm text-muted-foreground">暂无未关闭故障</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -284,7 +516,7 @@ type QueueItem = {
|
||||
detail: string;
|
||||
meta: string;
|
||||
title: string;
|
||||
variant: "danger" | "secondary" | "warning";
|
||||
variant: "danger" | "secondary" | "success" | "warning";
|
||||
};
|
||||
|
||||
function QueueCard({
|
||||
|
||||
@@ -39,7 +39,7 @@ export const navGroups: NavGroup[] = [
|
||||
path: "/dashboard",
|
||||
label: "运营看板",
|
||||
icon: "dashboard",
|
||||
anyPermissions: ["elder:read", "facility:read", "admission:read", "incident:read"],
|
||||
anyPermissions: ["elder:read", "facility:read", "admission:read", "incident:read", "care:read", "health:read", "device:read", "notice:read", "alert:read", "family:read", "ai:read"],
|
||||
},
|
||||
{
|
||||
path: "/elders",
|
||||
|
||||
Reference in New Issue
Block a user