feat: complete ops dashboard AI board

This commit is contained in:
2026-07-08 19:47:28 -07:00
parent 6a1add3422
commit a8776f9d79
14 changed files with 1401 additions and 189 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -93,6 +93,10 @@ export type ElderAiAnalysisHistoryItem = {
errorReason?: string;
};
export type ElderAiAnalysisBoardItem = ElderAiAnalysisHistoryItem & {
elderName: string;
};
export type KnowledgeEntryInput = {
scope: AiKnowledgeScope;
organizationId?: string;

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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({

View File

@@ -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",