Compare commits
2 Commits
6a1add3422
...
153c501cf7
| Author | SHA1 | Date | |
|---|---|---|---|
| 153c501cf7 | |||
| a8776f9d79 |
@@ -223,6 +223,76 @@ async function classifyOrder(orderData: OrderData) {
|
|||||||
| Invalid API key | Missing/wrong credentials | Check environment variables |
|
| Invalid API key | Missing/wrong credentials | Check environment variables |
|
||||||
| Schema validation failed | AI output doesn't match schema | Adjust schema or prompt |
|
| Schema validation failed | AI output doesn't match schema | Adjust schema or prompt |
|
||||||
|
|
||||||
|
|
||||||
|
## Scenario: Elder AI analysis latency controls
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- Trigger: backend elder AI analysis calls an OpenAI-compatible chat model through LangChain and returns a fixed API response to the UI.
|
||||||
|
- Apply this contract whenever changing `modules/ai/server/config.ts`, `modules/ai/server/analysis.ts`, or any elder AI analysis route that can block on a provider call.
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- Runtime config: `getAiRuntimeConfig() -> { success: true, config } | { success: false, reason }`.
|
||||||
|
- Config fields: `baseUrl`, `apiKey`, `chatModel`, `requestTimeoutMs`, `maxTokens`, `maxRetries`.
|
||||||
|
- Generation service: `generateElderAiAnalysis(context, elderId) -> ServiceResult<{ analysis }>`.
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- Environment keys:
|
||||||
|
- `AI_API_KEY`: required.
|
||||||
|
- `AI_BASE_URL`: optional OpenAI-compatible base URL.
|
||||||
|
- `AI_CHAT_MODEL`: optional model name.
|
||||||
|
- `AI_REQUEST_TIMEOUT_MS`: optional bounded integer request timeout.
|
||||||
|
- `AI_MAX_TOKENS`: optional bounded integer generation cap.
|
||||||
|
- `AI_MAX_RETRIES`: optional bounded integer retry count.
|
||||||
|
- `ChatOpenAI` construction must pass timeout, token cap, retry count, API key, model, and base URL from the runtime config.
|
||||||
|
- Provider errors must never persist prompts, resident context, API keys, raw provider messages, or stack traces in `elder_ai_analyses`.
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
- Missing `AI_API_KEY` -> return `AI_API_KEY 未配置`, persist failed history with `errorCategory: "missing_config"` when organization context exists.
|
||||||
|
- Provider timeout, `AbortError`, or timeout-like provider message -> return `AI 服务响应超时`, persist failed history with `errorCategory: "timeout"`.
|
||||||
|
- Other provider failure -> return `AI 服务暂时不可用`, persist failed history with `errorCategory: "provider_error"`.
|
||||||
|
- Invalid model output -> return `AI 返回结构不符合固定分析格式`, persist failed history with `errorCategory: "schema_validation_failed"`.
|
||||||
|
|
||||||
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
|
- Good: configured runtime uses a short timeout, bounded max tokens, and no hidden retry amplification; timeout failures are visible as sanitized failed history.
|
||||||
|
- Base: provider is slow or unreachable; user receives the timeout reason instead of waiting for default SDK retries and transport timeouts.
|
||||||
|
- Bad: service relies on LangChain/OpenAI defaults, causing long waits from high token caps, implicit retries, or unbounded request timeouts.
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- Unit: assert `generateElderAiAnalysis` constructs `ChatOpenAI` with runtime-configured `timeout`, `maxTokens`, and `maxRetries`.
|
||||||
|
- Unit: assert `AbortError` and timeout-like model failures persist `errorCategory: "timeout"`, return `AI 服务响应超时`, and do not persist provider secrets or prompt text.
|
||||||
|
- Existing failure tests must continue to assert provider errors and schema failures use sanitized persisted history.
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new ChatOpenAI({
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
maxTokens: 1800,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new ChatOpenAI({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
model: config.chatModel,
|
||||||
|
timeout: config.requestTimeoutMs,
|
||||||
|
maxTokens: config.maxTokens,
|
||||||
|
maxRetries: config.maxRetries,
|
||||||
|
configuration: config.baseUrl ? { baseURL: config.baseUrl } : undefined,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## 6. Prompt Engineering Best Practices
|
## 6. Prompt Engineering Best Practices
|
||||||
|
|
||||||
### Use XML Structure for Complex Prompts
|
### Use XML Structure for Complex Prompts
|
||||||
|
|||||||
4
.trellis/tasks/07-08-complete-ops-ai-board/check.jsonl
Normal file
4
.trellis/tasks/07-08-complete-ops-ai-board/check.jsonl
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{"file":".trellis/spec/backend/type-safety.md","reason":"Check backend AI aggregation and seed helper typing."}
|
||||||
|
{"file":".trellis/spec/frontend/components.md","reason":"Check dashboard rendering remains Server Component and uses persisted data only."}
|
||||||
|
{"file":".trellis/spec/shared/code-quality.md","reason":"Check focused tests, no dead code, no console logs, and no forbidden TypeScript patterns."}
|
||||||
|
{"file":".trellis/spec/guides/pre-implementation-checklist.md","reason":"Check cross-layer dashboard data flow and permission gates."}
|
||||||
61
.trellis/tasks/07-08-complete-ops-ai-board/design.md
Normal file
61
.trellis/tasks/07-08-complete-ops-ai-board/design.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
This task is a completion pass over existing persisted modules. It does not add schema or a new AI provider path.
|
||||||
|
|
||||||
|
- Collaboration data remains owned by `modules/devices`, `modules/notices`, `modules/alerts`, and `modules/family`.
|
||||||
|
- Dashboard data loading stays in the Server Component at `app/(app)/app/dashboard/page.tsx`.
|
||||||
|
- Presentation stays in `modules/dashboard/components/DashboardHome.tsx`.
|
||||||
|
- AI board aggregation belongs in `modules/ai/server/analysis.ts` because redaction depends on existing AI analysis scope rules.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. Dashboard page loads auth context and checks whether any visible dashboard section is permitted.
|
||||||
|
2. It conditionally calls existing operations for core care/health/emergency and collaboration modules.
|
||||||
|
3. It calls a new AI analysis listing function only when `ai:read` is present.
|
||||||
|
4. The AI service lists organization-scoped `elder_ai_analyses`, joins elders for display names, and maps each row through `canViewAnalysisScopes`.
|
||||||
|
5. The dashboard component receives only serializable data and renders queue cards/summary cards from that data.
|
||||||
|
|
||||||
|
## Seeding Strategy
|
||||||
|
|
||||||
|
Existing `seedDefaultWorkspaceData` exits early when core room/bed/elder/admission rows already exist. That protected existing workspaces, but it skipped collaboration seed records added later. Add a separate idempotent collaboration seeding helper that:
|
||||||
|
|
||||||
|
- reads existing rows by stable natural keys per table;
|
||||||
|
- inserts only missing default rows;
|
||||||
|
- resolves dependencies from existing or newly inserted rows;
|
||||||
|
- never updates or deletes existing operator data;
|
||||||
|
- runs both for fully new workspaces and for already-initialized workspaces.
|
||||||
|
|
||||||
|
Stable keys:
|
||||||
|
|
||||||
|
- devices: `code`
|
||||||
|
- tickets: `title`
|
||||||
|
- notices: `title`
|
||||||
|
- alert rules: `name`
|
||||||
|
- alert triggers: `title`
|
||||||
|
- family contacts: `elderId + name`
|
||||||
|
- family visits: `elderId + contactId + scheduled offset/status/notes` is not stable across reruns, so use `elderId + contactId + notes` for default records
|
||||||
|
- family feedback: `elderId + contactId + content`
|
||||||
|
|
||||||
|
## Permission Model
|
||||||
|
|
||||||
|
- Dashboard visibility expands to collaboration and AI read permissions.
|
||||||
|
- Collaboration sections call server operations only when their read permission is present.
|
||||||
|
- AI board calls the aggregation function only with `ai:read`.
|
||||||
|
- Redaction uses the existing `canViewAnalysisScopes` and `getPermissionForDataScope` rules, so a user may see that an analysis exists without seeing restricted result details.
|
||||||
|
|
||||||
|
## UI Shape
|
||||||
|
|
||||||
|
- Keep `DashboardHome` as a single Server-rendered component with small local helper render functions.
|
||||||
|
- Add collaboration queue cards for device tickets, notices, alert triggers, and family items.
|
||||||
|
- Add an AI analysis board card with risk/status badges, data scopes, created time, and a link back to the elder workspace.
|
||||||
|
- Empty states must be honest: no generated counts or placeholder rows.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
No schema migration is required. The seeding helper is additive and only inserts missing default data.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Rollback is limited to the AI aggregation helper, dashboard props/rendering, dashboard page conditional loads, and the additive seed helper. Existing collaboration CRUD modules remain untouched unless tests expose a direct integration bug.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{"file":".trellis/spec/backend/type-safety.md","reason":"Backend service results, scope narrowing, and no non-null assertions."}
|
||||||
|
{"file":".trellis/spec/frontend/components.md","reason":"Dashboard Server Component data loading, Kumo UI adapter use, and no fake operational data."}
|
||||||
|
{"file":".trellis/spec/shared/typescript.md","reason":"Shared TypeScript constraints for exported types and discriminated unions."}
|
||||||
|
{"file":".trellis/spec/shared/code-quality.md","reason":"No any, no non-null assertions, import ordering, and test expectations."}
|
||||||
|
{"file":".trellis/spec/guides/pre-implementation-checklist.md","reason":"Cross-layer readiness checks before dashboard and seed changes."}
|
||||||
34
.trellis/tasks/07-08-complete-ops-ai-board/implement.md
Normal file
34
.trellis/tasks/07-08-complete-ops-ai-board/implement.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## 1. Planning and Context
|
||||||
|
|
||||||
|
- [x] Read Trellis workflow context and relevant backend/frontend/shared specs.
|
||||||
|
- [x] Inspect existing collaboration modules, dashboard page/component, AI analysis service, permissions, and seed data.
|
||||||
|
- [x] Persist PRD, design, implementation plan, and curated spec manifests.
|
||||||
|
|
||||||
|
## 2. Seeding
|
||||||
|
|
||||||
|
- [x] Extract idempotent collaboration seed helper from the full-workspace seed path.
|
||||||
|
- [x] Call the helper for both existing core workspaces and newly seeded workspaces.
|
||||||
|
- [x] Preserve existing operator rows and insert only missing default records.
|
||||||
|
|
||||||
|
## 3. AI Analysis Board
|
||||||
|
|
||||||
|
- [x] Add an organization-scoped AI board listing function that joins elder names and redacts restricted analysis rows.
|
||||||
|
- [x] Add serializable dashboard board item types.
|
||||||
|
- [x] Keep existing elder-specific analysis APIs unchanged.
|
||||||
|
|
||||||
|
## 4. Dashboard Integration
|
||||||
|
|
||||||
|
- [x] Expand dashboard permission gate to collaboration and AI read permissions.
|
||||||
|
- [x] Conditionally load devices, notices, alerts, family, and AI board data by permission.
|
||||||
|
- [x] Render collaboration cards and AI analysis board from persisted data only.
|
||||||
|
- [x] Preserve slug-aware links via `getWorkspaceHref`.
|
||||||
|
|
||||||
|
## 5. Verification
|
||||||
|
|
||||||
|
- [x] Add focused tests for collaboration seed idempotency and AI board redaction.
|
||||||
|
- [x] Run focused tests covering changed code.
|
||||||
|
- [x] Run `pnpm type-check`.
|
||||||
|
- [x] Smoke-test the dashboard route when feasible via `pnpm build` route compilation.
|
||||||
|
- [x] Add latency controls for AI provider timeout, token cap, retry count, and sanitized timeout failure history.
|
||||||
30
.trellis/tasks/07-08-complete-ops-ai-board/prd.md
Normal file
30
.trellis/tasks/07-08-complete-ops-ai-board/prd.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Complete operations modules and AI analysis board
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Finish the currently visible operations surface by making collaboration modules fully reachable from the workspace/dashboard and adding a real persisted AI analysis board that summarizes elder AI analysis history without fabricating data.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Keep the existing persisted collaboration modules for devices, notices, alerts, and family visible through permission-aware workspace navigation and dashboard entry points.
|
||||||
|
- Seed collaboration-module records idempotently for already-initialized local/demo organizations without overwriting operator-created records.
|
||||||
|
- Show an AI analysis board from persisted `elder_ai_analyses` rows, joined to elder names, with permission-aware redaction for unavailable data scopes.
|
||||||
|
- Restrict AI board access to users with `ai:read`; never expose analysis content when underlying data-scope permissions are missing.
|
||||||
|
- Preserve organization scoping for all data loads and route links, including slug-aware `/app/{organizationSlug}/...` links.
|
||||||
|
- Reuse existing module server operations and UI adapters; do not add new dependencies or fake/static operational data.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] Existing collaboration modules remain data-backed and reachable from unscoped and organization-scoped workspace routes.
|
||||||
|
- [x] Existing workspaces with core resident data but no collaboration data receive missing default devices, notices, alerts, and family records on seed rerun.
|
||||||
|
- [x] The dashboard can be viewed by users with collaboration or AI read permissions, not only core care/facility permissions.
|
||||||
|
- [x] The dashboard renders collaboration queues/metrics only from persisted server queries and hides unavailable sections by permission.
|
||||||
|
- [x] The dashboard renders an AI analysis board sourced from persisted analysis history when `ai:read` is present.
|
||||||
|
- [x] AI analysis board items redact result content when `canViewAnalysisScopes` denies one or more stored data scopes.
|
||||||
|
- [x] Focused tests cover idempotent collaboration seeding and AI board redaction behavior.
|
||||||
|
- [x] `pnpm type-check` and focused tests pass.
|
||||||
|
- [x] Elder AI generation uses bounded provider timeout, token, and retry controls so slow providers fail with sanitized timeout history instead of hanging indefinitely.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Out of scope: background alert-rule execution, family portal/authentication, AI recommendation/business-action generation changes, and new database schema.
|
||||||
26
.trellis/tasks/07-08-complete-ops-ai-board/task.json
Normal file
26
.trellis/tasks/07-08-complete-ops-ai-board/task.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "complete-ops-ai-board",
|
||||||
|
"name": "complete-ops-ai-board",
|
||||||
|
"title": "Complete operations modules and AI analysis board",
|
||||||
|
"description": "",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-08",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { listAiAnalysisBoard } from "@/modules/ai/server/analysis";
|
||||||
|
import { listAlertCenterData } from "@/modules/alerts/server/operations";
|
||||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
import {
|
import {
|
||||||
listOperationalAdmissions,
|
listOperationalAdmissions,
|
||||||
@@ -11,12 +13,27 @@ import { hasPermission } from "@/modules/core/server/permissions";
|
|||||||
import type { BedStatus, Permission } from "@/modules/core/types";
|
import type { BedStatus, Permission } from "@/modules/core/types";
|
||||||
import { listCareExecutionData } from "@/modules/care/server/operations";
|
import { listCareExecutionData } from "@/modules/care/server/operations";
|
||||||
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
|
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
|
||||||
|
import { listDeviceOperationsData } from "@/modules/devices/server/operations";
|
||||||
import { listEmergencyIncidentData } from "@/modules/emergency/server/operations";
|
import { listEmergencyIncidentData } from "@/modules/emergency/server/operations";
|
||||||
|
import { listFamilyServiceData } from "@/modules/family/server/operations";
|
||||||
import { listHealthAdminData } from "@/modules/health/server/operations";
|
import { listHealthAdminData } from "@/modules/health/server/operations";
|
||||||
|
import { listNoticeCenterData } from "@/modules/notices/server/operations";
|
||||||
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"];
|
const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"];
|
||||||
const DASHBOARD_PERMISSIONS: Permission[] = ["elder:read", "facility:read", "admission:read", "incident:read", "care:read", "health:read"];
|
const DASHBOARD_PERMISSIONS: Permission[] = [
|
||||||
|
"elder:read",
|
||||||
|
"facility:read",
|
||||||
|
"admission:read",
|
||||||
|
"incident:read",
|
||||||
|
"care:read",
|
||||||
|
"health:read",
|
||||||
|
"device:read",
|
||||||
|
"notice:read",
|
||||||
|
"alert:read",
|
||||||
|
"family:read",
|
||||||
|
"ai:read",
|
||||||
|
];
|
||||||
|
|
||||||
export default async function DashboardPage(): Promise<React.ReactElement> {
|
export default async function DashboardPage(): Promise<React.ReactElement> {
|
||||||
const context = await getCurrentAuthContext();
|
const context = await getCurrentAuthContext();
|
||||||
@@ -30,18 +47,31 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
redirect(getWorkspaceHref(context.organization?.slug, "/family"));
|
redirect(getWorkspaceHref(context.organization?.slug, "/family"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canReadElders = hasPermission(context.permissions, "elder:read");
|
||||||
|
const canReadFacility = hasPermission(context.permissions, "facility:read");
|
||||||
|
const canReadAdmissions = hasPermission(context.permissions, "admission:read");
|
||||||
const canReadCare = hasPermission(context.permissions, "care:read");
|
const canReadCare = hasPermission(context.permissions, "care:read");
|
||||||
const canReadHealth = hasPermission(context.permissions, "health:read");
|
const canReadHealth = hasPermission(context.permissions, "health:read");
|
||||||
const canReadIncidents = hasPermission(context.permissions, "incident:read");
|
const canReadIncidents = hasPermission(context.permissions, "incident:read");
|
||||||
|
const canReadDevices = hasPermission(context.permissions, "device:read");
|
||||||
|
const canReadNotices = hasPermission(context.permissions, "notice:read");
|
||||||
|
const canReadAlerts = hasPermission(context.permissions, "alert:read");
|
||||||
|
const canReadFamily = hasPermission(context.permissions, "family:read");
|
||||||
|
const canReadAi = hasPermission(context.permissions, "ai:read");
|
||||||
|
|
||||||
const [elders, beds, admissions, incidents, careData, healthData, emergencyData] = await Promise.all([
|
const [elders, beds, admissions, incidents, careData, healthData, emergencyData, deviceData, noticeData, alertData, familyData, aiBoardResult] = await Promise.all([
|
||||||
listOperationalElders(organizationId),
|
organizationId && canReadElders ? listOperationalElders(organizationId) : [],
|
||||||
listOperationalBeds(organizationId),
|
organizationId && (canReadFacility || canReadAdmissions) ? listOperationalBeds(organizationId) : [],
|
||||||
listOperationalAdmissions(organizationId),
|
organizationId && canReadAdmissions ? listOperationalAdmissions(organizationId) : [],
|
||||||
listOperationalIncidents(organizationId),
|
organizationId && canReadIncidents ? listOperationalIncidents(organizationId) : [],
|
||||||
organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined,
|
organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined,
|
||||||
organizationId && canReadHealth ? listHealthAdminData(organizationId) : undefined,
|
organizationId && canReadHealth ? listHealthAdminData(organizationId) : undefined,
|
||||||
organizationId && canReadIncidents ? listEmergencyIncidentData(organizationId) : undefined,
|
organizationId && canReadIncidents ? listEmergencyIncidentData(organizationId) : undefined,
|
||||||
|
organizationId && canReadDevices ? listDeviceOperationsData(organizationId) : undefined,
|
||||||
|
organizationId && canReadNotices ? listNoticeCenterData(organizationId, context.account.id) : undefined,
|
||||||
|
organizationId && canReadAlerts ? listAlertCenterData(organizationId) : undefined,
|
||||||
|
organizationId && canReadFamily ? listFamilyServiceData(organizationId) : undefined,
|
||||||
|
canReadAi ? listAiAnalysisBoard(context, 6) : undefined,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeElders = elders.filter((elder) => elder.status === "active").length;
|
const activeElders = elders.filter((elder) => elder.status === "active").length;
|
||||||
@@ -56,31 +86,50 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0;
|
const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0;
|
||||||
const openEmergency = emergencyData?.metrics.open ?? 0;
|
const openEmergency = emergencyData?.metrics.open ?? 0;
|
||||||
const criticalEmergency = emergencyData?.metrics.critical ?? 0;
|
const criticalEmergency = emergencyData?.metrics.critical ?? 0;
|
||||||
|
const openDeviceTickets = deviceData?.metrics.openTickets ?? 0;
|
||||||
|
const urgentDeviceTickets = deviceData?.metrics.urgentTickets ?? 0;
|
||||||
|
const publishedNotices = noticeData?.metrics.published ?? 0;
|
||||||
|
const unreadNotices = noticeData?.metrics.unread ?? 0;
|
||||||
|
const openAlertTriggers = alertData?.metrics.openTriggers ?? 0;
|
||||||
|
const criticalAlertTriggers = alertData?.metrics.criticalTriggers ?? 0;
|
||||||
|
const pendingFamilyVisits = familyData?.metrics.pendingVisits ?? 0;
|
||||||
|
const openFamilyFeedback = familyData?.metrics.openFeedback ?? 0;
|
||||||
|
const aiBoardItems = aiBoardResult?.success ? aiBoardResult.data.items : undefined;
|
||||||
|
|
||||||
const metrics: DashboardMetric[] = [
|
const metrics: DashboardMetric[] = [
|
||||||
{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" },
|
...(canReadElders ? [{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" as const }] : []),
|
||||||
{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" },
|
...(canReadFacility || canReadAdmissions ? [{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" as const }] : []),
|
||||||
{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" },
|
...(canReadCare ? [{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" as const }] : []),
|
||||||
{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" },
|
...(canReadHealth ? [{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" as const }] : []),
|
||||||
{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" },
|
...(canReadAdmissions ? [{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" as const }] : []),
|
||||||
{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" },
|
...(canReadIncidents ? [{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" as const }] : []),
|
||||||
|
...(canReadDevices ? [{ label: "设备工单", value: String(openDeviceTickets), trend: `紧急 ${urgentDeviceTickets}`, icon: "devices" as const }] : []),
|
||||||
|
...(canReadNotices ? [{ label: "公告通知", value: String(publishedNotices), trend: `未读 ${unreadNotices}`, icon: "notices" as const }] : []),
|
||||||
|
...(canReadAlerts ? [{ label: "规则预警", value: String(openAlertTriggers), trend: `关键 ${criticalAlertTriggers}`, icon: "alerts" as const }] : []),
|
||||||
|
...(canReadFamily ? [{ label: "家属服务", value: String(pendingFamilyVisits + openFamilyFeedback), trend: `探访 ${pendingFamilyVisits} / 反馈 ${openFamilyFeedback}`, icon: "family" as const }] : []),
|
||||||
|
...(canReadAi && aiBoardItems ? [{ label: "智能分析", value: String(aiBoardItems.length), trend: "已保存分析历史", icon: "ai" as const }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const occupancyData = BED_STATUSES.map((status) => ({
|
const occupancyData = canReadFacility || canReadAdmissions
|
||||||
|
? BED_STATUSES.map((status) => ({
|
||||||
label: status,
|
label: status,
|
||||||
count: beds.filter((bed) => bed.status === status).length,
|
count: beds.filter((bed) => bed.status === status).length,
|
||||||
}));
|
}))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const recentAdmissions = admissions.slice(0, 6).map((admission) => ({
|
const recentAdmissions = canReadAdmissions
|
||||||
|
? admissions.slice(0, 6).map((admission) => ({
|
||||||
id: admission.id,
|
id: admission.id,
|
||||||
elderName: admission.elderName,
|
elderName: admission.elderName,
|
||||||
bedLabel: `${admission.roomName}-${admission.bedCode}`,
|
bedLabel: `${admission.roomName}-${admission.bedCode}`,
|
||||||
status: admission.status,
|
status: admission.status,
|
||||||
admittedAt: admission.admittedAt,
|
admittedAt: admission.admittedAt,
|
||||||
dischargedAt: admission.dischargedAt,
|
dischargedAt: admission.dischargedAt,
|
||||||
}));
|
}))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const visibleIncidents = incidents
|
const visibleIncidents = canReadIncidents
|
||||||
|
? incidents
|
||||||
.filter((incident) => incident.status !== "closed")
|
.filter((incident) => incident.status !== "closed")
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((incident) => ({
|
.map((incident) => ({
|
||||||
@@ -90,8 +139,9 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
title: incident.title,
|
title: incident.title,
|
||||||
source: incident.source,
|
source: incident.source,
|
||||||
createdAt: incident.createdAt,
|
createdAt: incident.createdAt,
|
||||||
}));
|
}))
|
||||||
const careTasks = (careData?.tasks ?? [])
|
: undefined;
|
||||||
|
const careTasks = careData?.tasks
|
||||||
.filter((task) => task.status === "pending" || task.status === "in_progress" || task.priority === "urgent" || task.priority === "high")
|
.filter((task) => task.status === "pending" || task.status === "in_progress" || task.priority === "urgent" || task.priority === "high")
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((task) => ({
|
.map((task) => ({
|
||||||
@@ -103,7 +153,7 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
status: task.status,
|
status: task.status,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
}));
|
}));
|
||||||
const healthReviews = (healthData?.reviews ?? [])
|
const healthReviews = healthData?.reviews
|
||||||
.filter((review) => review.status === "pending" || review.severity === "critical")
|
.filter((review) => review.status === "pending" || review.severity === "critical")
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((review) => ({
|
.map((review) => ({
|
||||||
@@ -113,7 +163,7 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
status: review.status,
|
status: review.status,
|
||||||
title: review.title,
|
title: review.title,
|
||||||
}));
|
}));
|
||||||
const emergencyIncidents = (emergencyData?.incidents ?? [])
|
const emergencyIncidents = emergencyData?.incidents
|
||||||
.filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical")
|
.filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical")
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((incident) => ({
|
.map((incident) => ({
|
||||||
@@ -124,18 +174,76 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
source: incident.source,
|
source: incident.source,
|
||||||
createdAt: incident.createdAt,
|
createdAt: incident.createdAt,
|
||||||
}));
|
}));
|
||||||
|
const deviceTickets = deviceData?.tickets
|
||||||
|
.filter((ticket) => ticket.status === "open" || ticket.status === "assigned" || ticket.priority === "urgent" || ticket.priority === "high")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((ticket) => ({
|
||||||
|
id: ticket.id,
|
||||||
|
deviceName: ticket.deviceName,
|
||||||
|
priority: ticket.priority,
|
||||||
|
status: ticket.status,
|
||||||
|
title: ticket.title,
|
||||||
|
updatedAt: ticket.updatedAt,
|
||||||
|
}));
|
||||||
|
const notices = noticeData?.notices.slice(0, 6).map((notice) => ({
|
||||||
|
id: notice.id,
|
||||||
|
audience: notice.audience,
|
||||||
|
status: notice.status,
|
||||||
|
title: notice.title,
|
||||||
|
updatedAt: notice.updatedAt,
|
||||||
|
}));
|
||||||
|
const alertTriggers = alertData?.triggers
|
||||||
|
.filter((trigger) => trigger.status === "open" || trigger.status === "acknowledged")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((trigger) => ({
|
||||||
|
id: trigger.id,
|
||||||
|
elderName: trigger.elderName,
|
||||||
|
status: trigger.status,
|
||||||
|
title: trigger.title,
|
||||||
|
updatedAt: trigger.updatedAt,
|
||||||
|
}));
|
||||||
|
const familyVisits = familyData?.visits
|
||||||
|
.filter((visit) => visit.status === "requested" || visit.status === "approved")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((visit) => ({
|
||||||
|
id: visit.id,
|
||||||
|
contactName: visit.contactName,
|
||||||
|
elderName: visit.elderName,
|
||||||
|
scheduledAt: visit.scheduledAt,
|
||||||
|
status: visit.status,
|
||||||
|
}));
|
||||||
|
const familyFeedback = familyData?.feedback
|
||||||
|
.filter((feedback) => feedback.status === "open" || feedback.status === "in_progress")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((feedback) => ({
|
||||||
|
id: feedback.id,
|
||||||
|
elderName: feedback.elderName,
|
||||||
|
status: feedback.status,
|
||||||
|
updatedAt: feedback.updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardHome
|
<DashboardHome
|
||||||
|
alertTriggers={alertTriggers}
|
||||||
|
alertsHref={getWorkspaceHref(context.organization?.slug, "/alerts")}
|
||||||
|
aiBoardItems={canReadAi ? aiBoardItems ?? [] : undefined}
|
||||||
bedsHref={getWorkspaceHref(context.organization?.slug, "/beds")}
|
bedsHref={getWorkspaceHref(context.organization?.slug, "/beds")}
|
||||||
careHref={getWorkspaceHref(context.organization?.slug, "/care")}
|
careHref={getWorkspaceHref(context.organization?.slug, "/care")}
|
||||||
careTasks={careTasks}
|
careTasks={careTasks}
|
||||||
|
deviceTickets={deviceTickets}
|
||||||
|
devicesHref={getWorkspaceHref(context.organization?.slug, "/devices")}
|
||||||
|
eldersHref={getWorkspaceHref(context.organization?.slug, "/elders")}
|
||||||
emergencyHref={getWorkspaceHref(context.organization?.slug, "/emergency")}
|
emergencyHref={getWorkspaceHref(context.organization?.slug, "/emergency")}
|
||||||
emergencyIncidents={emergencyIncidents}
|
emergencyIncidents={emergencyIncidents}
|
||||||
|
familyFeedback={familyFeedback}
|
||||||
|
familyHref={getWorkspaceHref(context.organization?.slug, "/family")}
|
||||||
|
familyVisits={familyVisits}
|
||||||
healthHref={getWorkspaceHref(context.organization?.slug, "/health")}
|
healthHref={getWorkspaceHref(context.organization?.slug, "/health")}
|
||||||
healthReviews={healthReviews}
|
healthReviews={healthReviews}
|
||||||
incidents={visibleIncidents}
|
incidents={visibleIncidents}
|
||||||
metrics={metrics}
|
metrics={metrics}
|
||||||
|
notices={notices}
|
||||||
|
noticesHref={getWorkspaceHref(context.organization?.slug, "/notices")}
|
||||||
occupancyData={occupancyData}
|
occupancyData={occupancyData}
|
||||||
recentAdmissions={recentAdmissions}
|
recentAdmissions={recentAdmissions}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { ChatOpenAI } from "@langchain/openai";
|
import { ChatOpenAI } from "@langchain/openai";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
|
|
||||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
||||||
import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context";
|
import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context";
|
||||||
import { buildElderAiContext } 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 { retrieveKnowledge } from "@/modules/ai/server/knowledge";
|
||||||
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
|
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
|
||||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||||
@@ -65,6 +64,11 @@ type AnalysisRow = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ElderNameRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AnalysisInsertPayload = {
|
type AnalysisInsertPayload = {
|
||||||
organizationId?: unknown;
|
organizationId?: unknown;
|
||||||
elderId?: unknown;
|
elderId?: unknown;
|
||||||
@@ -90,14 +94,17 @@ type ReturnlessMock = ReturnlessFunction & {
|
|||||||
|
|
||||||
type ReturnlessFunction = (...args: unknown[]) => unknown;
|
type ReturnlessFunction = (...args: unknown[]) => unknown;
|
||||||
|
|
||||||
const runtimeConfig: AiRuntimeConfigResult = {
|
const runtimeConfig = {
|
||||||
success: true,
|
success: true,
|
||||||
config: {
|
config: {
|
||||||
baseUrl: "https://ai.example.test/v1",
|
baseUrl: "https://ai.example.test/v1",
|
||||||
apiKey: "test-api-key",
|
apiKey: "test-api-key",
|
||||||
chatModel: "gpt-test",
|
chatModel: "gpt-test",
|
||||||
|
requestTimeoutMs: 12_345,
|
||||||
|
maxTokens: 901,
|
||||||
|
maxRetries: 4,
|
||||||
},
|
},
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
const account: AuthContext["account"] = {
|
const account: AuthContext["account"] = {
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
@@ -258,8 +265,9 @@ function createAnalysisRow(values: AnalysisInsertPayload, index: number): Analys
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFake {
|
function createDatabaseFake(selectRows: AnalysisRow[] = [], elderRows: ElderNameRow[] = []): AnalysisDatabaseFake {
|
||||||
const insertedValues: AnalysisInsertPayload[] = [];
|
const insertedValues: AnalysisInsertPayload[] = [];
|
||||||
|
const orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
|
||||||
const insert = vi.fn(() => ({
|
const insert = vi.fn(() => ({
|
||||||
values: vi.fn((values: AnalysisInsertPayload) => {
|
values: vi.fn((values: AnalysisInsertPayload) => {
|
||||||
insertedValues.push(values);
|
insertedValues.push(values);
|
||||||
@@ -268,15 +276,23 @@ function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFak
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
const select = vi.fn(() => ({
|
const select = vi.fn((selection?: unknown) => {
|
||||||
|
const selectsElderNames = selection !== null && typeof selection === "object" && "name" in selection;
|
||||||
|
return {
|
||||||
from: vi.fn(() => ({
|
from: vi.fn(() => ({
|
||||||
where: vi.fn(() => ({
|
where: vi.fn(() => {
|
||||||
|
if (selectsElderNames) {
|
||||||
|
return elderRows;
|
||||||
|
}
|
||||||
|
return {
|
||||||
orderBy: vi.fn(() => ({
|
orderBy: vi.fn(() => ({
|
||||||
limit: vi.fn(async () => selectRows),
|
limit: vi.fn(async (rowLimit: number) => orderedSelectRows.slice(0, rowLimit)),
|
||||||
})),
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
})),
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
return { insertedValues, insert, select };
|
return { insertedValues, insert, select };
|
||||||
}
|
}
|
||||||
@@ -313,6 +329,52 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("elder AI analysis service", () => {
|
describe("elder AI analysis service", () => {
|
||||||
|
it("constructs ChatOpenAI with latency controls from AI runtime config", async () => {
|
||||||
|
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(ChatOpenAI).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
apiKey: "test-api-key",
|
||||||
|
model: "gpt-test",
|
||||||
|
timeout: 12_345,
|
||||||
|
maxTokens: 901,
|
||||||
|
maxRetries: 4,
|
||||||
|
configuration: { baseURL: "https://ai.example.test/v1" },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: "AbortError",
|
||||||
|
error: Object.assign(new Error("aborted request with sk-live-secret and resident prompt"), { name: "AbortError" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "timeout-like provider message",
|
||||||
|
error: new Error("request timed out after 12345ms with sk-live-secret and resident prompt"),
|
||||||
|
},
|
||||||
|
])("maps $name model failures to sanitized timeout failed history", async ({ error }) => {
|
||||||
|
const database = createDatabaseFake();
|
||||||
|
useDatabase(database);
|
||||||
|
chatMocks.invoke.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, reason: "AI 服务响应超时", status: 502 });
|
||||||
|
expect(database.insertedValues).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "failed",
|
||||||
|
dataScopes: ["elder", "health"],
|
||||||
|
errorCategory: "timeout",
|
||||||
|
errorReason: "AI 服务响应超时",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const persistedFailure = JSON.stringify(database.insertedValues[0]);
|
||||||
|
expect(persistedFailure).not.toContain("sk-live-secret");
|
||||||
|
expect(persistedFailure).not.toContain("resident prompt");
|
||||||
|
expect(persistedFailure).not.toContain("Night wandering");
|
||||||
|
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务响应超时" }));
|
||||||
|
});
|
||||||
|
|
||||||
it("saves sanitized failed history when AI runtime config is missing", async () => {
|
it("saves sanitized failed history when AI runtime config is missing", async () => {
|
||||||
const database = createDatabaseFake();
|
const database = createDatabaseFake();
|
||||||
useDatabase(database);
|
useDatabase(database);
|
||||||
@@ -442,6 +504,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 () => {
|
it("rejects model output that cites IDs outside resident and knowledge citations", async () => {
|
||||||
const database = createDatabaseFake();
|
const database = createDatabaseFake();
|
||||||
useDatabase(database);
|
useDatabase(database);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
|
|||||||
import type {
|
import type {
|
||||||
AiCitation,
|
AiCitation,
|
||||||
AiErrorCategory,
|
AiErrorCategory,
|
||||||
|
ElderAiAnalysisBoardItem,
|
||||||
ElderAiAnalysisHistoryItem,
|
ElderAiAnalysisHistoryItem,
|
||||||
ElderAiAnalysisOutput,
|
ElderAiAnalysisOutput,
|
||||||
} from "@/modules/ai/types";
|
} from "@/modules/ai/types";
|
||||||
@@ -19,19 +20,28 @@ import {
|
|||||||
} from "@/modules/ai/types";
|
} from "@/modules/ai/types";
|
||||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||||
import { getDatabase } from "@/modules/core/server/db";
|
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";
|
import type { AuthContext } from "@/modules/core/types";
|
||||||
|
|
||||||
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
|
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
|
||||||
type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
|
type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
|
||||||
|
|
||||||
function createChatModel(config: { baseUrl: string; apiKey: string; chatModel: string }) {
|
function createChatModel(config: {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
chatModel: string;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
maxTokens: number;
|
||||||
|
maxRetries: number;
|
||||||
|
}) {
|
||||||
const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined;
|
const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined;
|
||||||
return new ChatOpenAI({
|
return new ChatOpenAI({
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
model: config.chatModel,
|
model: config.chatModel,
|
||||||
maxTokens: 1800,
|
maxTokens: config.maxTokens,
|
||||||
|
maxRetries: config.maxRetries,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
|
timeout: config.requestTimeoutMs,
|
||||||
configuration,
|
configuration,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -41,9 +51,22 @@ function toIsoString(value: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapErrorCategory(error: unknown): AiErrorCategory {
|
function mapErrorCategory(error: unknown): AiErrorCategory {
|
||||||
if (error instanceof Error && error.name.toLowerCase().includes("timeout")) {
|
if (!(error instanceof Error)) {
|
||||||
|
return "provider_error";
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = error.name.toLowerCase();
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
if (
|
||||||
|
name.includes("abort") ||
|
||||||
|
name.includes("timeout") ||
|
||||||
|
message.includes("abort") ||
|
||||||
|
message.includes("timeout") ||
|
||||||
|
message.includes("timed out")
|
||||||
|
) {
|
||||||
return "timeout";
|
return "timeout";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "provider_error";
|
return "provider_error";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +225,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(
|
export async function generateElderAiAnalysis(
|
||||||
context: AuthContext,
|
context: AuthContext,
|
||||||
elderId: string,
|
elderId: string,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ export type AiRuntimeConfig = {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
chatModel: string;
|
chatModel: string;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
maxTokens: number;
|
||||||
|
maxRetries: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiRuntimeConfigResult =
|
export type AiRuntimeConfigResult =
|
||||||
@@ -10,11 +13,28 @@ export type AiRuntimeConfigResult =
|
|||||||
| { success: false; reason: string };
|
| { success: false; reason: string };
|
||||||
|
|
||||||
const DEFAULT_CHAT_MODEL = "gpt-4.1-mini";
|
const DEFAULT_CHAT_MODEL = "gpt-4.1-mini";
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
|
||||||
|
const DEFAULT_MAX_TOKENS = 1_000;
|
||||||
|
const DEFAULT_MAX_RETRIES = 0;
|
||||||
|
|
||||||
function readOptionalEnv(name: string): string {
|
function readOptionalEnv(name: string): string {
|
||||||
return process.env[name]?.trim() ?? "";
|
return process.env[name]?.trim() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBoundedIntegerEnv(name: string, fallback: number, min: number, max: number): number {
|
||||||
|
const rawValue = readOptionalEnv(name);
|
||||||
|
if (!rawValue) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(rawValue);
|
||||||
|
if (!Number.isInteger(value) || value < min || value > max) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function getAiRuntimeConfig(): AiRuntimeConfigResult {
|
export function getAiRuntimeConfig(): AiRuntimeConfigResult {
|
||||||
const apiKey = readOptionalEnv("AI_API_KEY");
|
const apiKey = readOptionalEnv("AI_API_KEY");
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -27,6 +47,10 @@ export function getAiRuntimeConfig(): AiRuntimeConfigResult {
|
|||||||
baseUrl: readOptionalEnv("AI_BASE_URL"),
|
baseUrl: readOptionalEnv("AI_BASE_URL"),
|
||||||
apiKey,
|
apiKey,
|
||||||
chatModel: readOptionalEnv("AI_CHAT_MODEL") || DEFAULT_CHAT_MODEL,
|
chatModel: readOptionalEnv("AI_CHAT_MODEL") || DEFAULT_CHAT_MODEL,
|
||||||
|
requestTimeoutMs: readBoundedIntegerEnv("AI_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, 1_000, 120_000),
|
||||||
|
maxTokens: readBoundedIntegerEnv("AI_MAX_TOKENS", DEFAULT_MAX_TOKENS, 256, 2_000),
|
||||||
|
maxRetries: readBoundedIntegerEnv("AI_MAX_RETRIES", DEFAULT_MAX_RETRIES, 0, 5),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ export type ElderAiAnalysisHistoryItem = {
|
|||||||
errorReason?: string;
|
errorReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ElderAiAnalysisBoardItem = ElderAiAnalysisHistoryItem & {
|
||||||
|
elderName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type KnowledgeEntryInput = {
|
export type KnowledgeEntryInput = {
|
||||||
scope: AiKnowledgeScope;
|
scope: AiKnowledgeScope;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types";
|
import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types";
|
||||||
|
import { getDatabase, type AppDatabase } from "@/modules/core/server/db";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AI_KNOWLEDGE_ENTRIES,
|
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,
|
DEFAULT_PREPARED_AI_ANALYSES,
|
||||||
|
seedDefaultCollaborationWorkspaceData,
|
||||||
} from "@/modules/core/server/default-workspace-data";
|
} from "@/modules/core/server/default-workspace-data";
|
||||||
|
|
||||||
const FORBIDDEN_DEMO_WORDING_PATTERN = /mock|simulation|simulated|demo|sample|placeholder|fixture|fake|test data|模拟|仿真|演示|样例|示例|占位|测试数据/i;
|
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(),
|
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", () => {
|
describe("default AI workspace seed data", () => {
|
||||||
it("keeps user-visible AI knowledge content realistic and validator-ready", () => {
|
it("keeps user-visible AI knowledge content realistic and validator-ready", () => {
|
||||||
expect(DEFAULT_AI_KNOWLEDGE_ENTRIES.length).toBeGreaterThanOrEqual(3);
|
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 床头呼叫器",
|
name: "A102 床头呼叫器",
|
||||||
code: "DEV-CALL-A102-2",
|
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",
|
deviceCode: "DEV-CALL-A102-2",
|
||||||
title: "床头呼叫器按键失灵",
|
title: "床头呼叫器按键失灵",
|
||||||
@@ -1048,7 +1048,7 @@ const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_NOTICES: SeedNotice[] = [
|
export const DEFAULT_NOTICES: SeedNotice[] = [
|
||||||
{
|
{
|
||||||
title: "本周家属探访安排",
|
title: "本周家属探访安排",
|
||||||
content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。",
|
content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。",
|
||||||
@@ -1071,7 +1071,7 @@ const DEFAULT_NOTICES: SeedNotice[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
|
export const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
|
||||||
{
|
{
|
||||||
name: "血氧低值连续告警",
|
name: "血氧低值连续告警",
|
||||||
ruleType: "health",
|
ruleType: "health",
|
||||||
@@ -1098,7 +1098,7 @@ const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
|
export const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
|
||||||
{
|
{
|
||||||
ruleName: "血氧低值连续告警",
|
ruleName: "血氧低值连续告警",
|
||||||
elderName: "钱松柏",
|
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: "13800000001", status: "active", notes: "主要联系人,每周六探访。" },
|
||||||
{ elderName: "钱松柏", name: "钱宁", relationship: "儿子", phone: "13800000011", status: "active", notes: "关注夜间吸氧情况。" },
|
{ elderName: "钱松柏", name: "钱宁", relationship: "儿子", phone: "13800000011", status: "active", notes: "关注夜间吸氧情况。" },
|
||||||
{ elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", status: "active", notes: "短住托养资料已确认。" },
|
{ elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", status: "active", notes: "短住托养资料已确认。" },
|
||||||
{ elderName: "赵国华", name: "赵蕾", relationship: "孙女", phone: "13800000016", 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: 30, status: "approved", notes: "安排护理楼一层会客区。" },
|
||||||
{ elderName: "钱松柏", contactName: "钱宁", scheduledHoursOffset: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" },
|
{ elderName: "钱松柏", contactName: "钱宁", scheduledHoursOffset: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" },
|
||||||
{ elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" },
|
{ elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
|
export const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
|
||||||
{
|
{
|
||||||
elderName: "赵国华",
|
elderName: "赵国华",
|
||||||
contactName: "赵蕾",
|
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 {
|
function hasRows(rows: unknown[]): boolean {
|
||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
}
|
}
|
||||||
@@ -1524,6 +1789,7 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
|
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
|
||||||
|
await seedDefaultCollaborationWorkspaceData(organizationId);
|
||||||
await seedDefaultAiWorkspaceData(organizationId);
|
await seedDefaultAiWorkspaceData(organizationId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1983,5 +2249,6 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await seedDefaultCollaborationWorkspaceData(organizationId);
|
||||||
await seedDefaultAiWorkspaceData(organizationId);
|
await seedDefaultAiWorkspaceData(organizationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
import Link from "next/link";
|
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 type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Table } from "@/components/ui/table";
|
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 { AdmissionStatus, BedStatus, IncidentSeverity } from "@/modules/core/types";
|
||||||
import type { CareTaskPriority, CareTaskStatus } from "@/modules/care/types";
|
import type { CareTaskPriority, CareTaskStatus } from "@/modules/care/types";
|
||||||
import { CARE_TASK_PRIORITY_LABELS, CARE_TASK_STATUS_LABELS } 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 { 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 { 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 type { HealthReviewSeverity, HealthReviewStatus } from "@/modules/health/types";
|
||||||
import { HEALTH_REVIEW_SEVERITY_LABELS, HEALTH_REVIEW_STATUS_LABELS } 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 = {
|
export type DashboardMetric = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
trend: 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 = {
|
export type DashboardAdmission = {
|
||||||
@@ -56,26 +64,82 @@ export type DashboardHealthReview = {
|
|||||||
title: string;
|
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 = {
|
type DashboardHomeProps = {
|
||||||
|
alertTriggers?: DashboardAlertTrigger[];
|
||||||
|
alertsHref: string;
|
||||||
|
aiBoardItems?: ElderAiAnalysisBoardItem[];
|
||||||
bedsHref: string;
|
bedsHref: string;
|
||||||
careHref: string;
|
careHref: string;
|
||||||
careTasks: DashboardCareTask[];
|
careTasks?: DashboardCareTask[];
|
||||||
|
deviceTickets?: DashboardDeviceTicket[];
|
||||||
|
devicesHref: string;
|
||||||
|
eldersHref: string;
|
||||||
emergencyHref: string;
|
emergencyHref: string;
|
||||||
emergencyIncidents: DashboardIncident[];
|
emergencyIncidents?: DashboardIncident[];
|
||||||
|
familyFeedback?: DashboardFamilyFeedback[];
|
||||||
|
familyHref: string;
|
||||||
|
familyVisits?: DashboardFamilyVisit[];
|
||||||
healthHref: string;
|
healthHref: string;
|
||||||
healthReviews: DashboardHealthReview[];
|
healthReviews?: DashboardHealthReview[];
|
||||||
metrics: DashboardMetric[];
|
metrics: DashboardMetric[];
|
||||||
occupancyData: BedOccupancyDatum[];
|
notices?: DashboardNotice[];
|
||||||
recentAdmissions: DashboardAdmission[];
|
noticesHref: string;
|
||||||
incidents: DashboardIncident[];
|
occupancyData?: BedOccupancyDatum[];
|
||||||
|
recentAdmissions?: DashboardAdmission[];
|
||||||
|
incidents?: DashboardIncident[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
|
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
|
||||||
admissions: CheckCircle2,
|
admissions: CheckCircle2,
|
||||||
|
ai: BrainCircuit,
|
||||||
|
alerts: ShieldAlert,
|
||||||
beds: BedDouble,
|
beds: BedDouble,
|
||||||
care: ListChecks,
|
care: ListChecks,
|
||||||
|
devices: TabletSmartphone,
|
||||||
|
family: Users,
|
||||||
health: HeartPulse,
|
health: HeartPulse,
|
||||||
incidents: HeartPulse,
|
incidents: HeartPulse,
|
||||||
|
notices: Megaphone,
|
||||||
users: Users,
|
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({
|
export function DashboardHome({
|
||||||
|
alertTriggers,
|
||||||
|
alertsHref,
|
||||||
|
aiBoardItems,
|
||||||
bedsHref,
|
bedsHref,
|
||||||
careHref,
|
careHref,
|
||||||
careTasks,
|
careTasks,
|
||||||
|
deviceTickets,
|
||||||
|
devicesHref,
|
||||||
|
eldersHref,
|
||||||
emergencyHref,
|
emergencyHref,
|
||||||
emergencyIncidents,
|
emergencyIncidents,
|
||||||
|
familyFeedback,
|
||||||
|
familyHref,
|
||||||
|
familyVisits,
|
||||||
healthHref,
|
healthHref,
|
||||||
healthReviews,
|
healthReviews,
|
||||||
metrics,
|
metrics,
|
||||||
|
notices,
|
||||||
|
noticesHref,
|
||||||
occupancyData,
|
occupancyData,
|
||||||
recentAdmissions,
|
recentAdmissions,
|
||||||
incidents,
|
incidents,
|
||||||
}: DashboardHomeProps): React.ReactElement {
|
}: 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 (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
<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">
|
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
@@ -148,7 +265,9 @@ export function DashboardHome({
|
|||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{hasCoreQueues ? (
|
||||||
<section className="grid gap-4 xl:grid-cols-3">
|
<section className="grid gap-4 xl:grid-cols-3">
|
||||||
|
{careTasks !== undefined ? (
|
||||||
<QueueCard
|
<QueueCard
|
||||||
emptyText="暂无待处理护理任务"
|
emptyText="暂无待处理护理任务"
|
||||||
href={careHref}
|
href={careHref}
|
||||||
@@ -162,6 +281,8 @@ export function DashboardHome({
|
|||||||
}))}
|
}))}
|
||||||
title="护理待办"
|
title="护理待办"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{healthReviews !== undefined ? (
|
||||||
<QueueCard
|
<QueueCard
|
||||||
emptyText="暂无待复核健康异常"
|
emptyText="暂无待复核健康异常"
|
||||||
href={healthHref}
|
href={healthHref}
|
||||||
@@ -175,6 +296,8 @@ export function DashboardHome({
|
|||||||
}))}
|
}))}
|
||||||
title="健康复核"
|
title="健康复核"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{emergencyIncidents !== undefined ? (
|
||||||
<QueueCard
|
<QueueCard
|
||||||
emptyText="暂无待处理应急事件"
|
emptyText="暂无待处理应急事件"
|
||||||
href={emergencyHref}
|
href={emergencyHref}
|
||||||
@@ -188,9 +311,71 @@ export function DashboardHome({
|
|||||||
}))}
|
}))}
|
||||||
title="安全应急"
|
title="安全应急"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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}
|
||||||
|
|
||||||
|
{occupancyData !== undefined || recentAdmissions !== undefined ? (
|
||||||
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
|
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
{occupancyData !== undefined ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -207,7 +392,9 @@ export function DashboardHome({
|
|||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{recentAdmissions !== undefined ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>最近入住记录</CardTitle>
|
<CardTitle>最近入住记录</CardTitle>
|
||||||
@@ -250,8 +437,52 @@ export function DashboardHome({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
</section>
|
</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>
|
||||||
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>未关闭故障</CardTitle>
|
<CardTitle>未关闭故障</CardTitle>
|
||||||
@@ -274,6 +505,7 @@ export function DashboardHome({
|
|||||||
{incidents.length === 0 ? <p className="text-sm text-muted-foreground">暂无未关闭故障</p> : null}
|
{incidents.length === 0 ? <p className="text-sm text-muted-foreground">暂无未关闭故障</p> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -284,7 +516,7 @@ type QueueItem = {
|
|||||||
detail: string;
|
detail: string;
|
||||||
meta: string;
|
meta: string;
|
||||||
title: string;
|
title: string;
|
||||||
variant: "danger" | "secondary" | "warning";
|
variant: "danger" | "secondary" | "success" | "warning";
|
||||||
};
|
};
|
||||||
|
|
||||||
function QueueCard({
|
function QueueCard({
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
label: "运营看板",
|
label: "运营看板",
|
||||||
icon: "dashboard",
|
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",
|
path: "/elders",
|
||||||
|
|||||||
Reference in New Issue
Block a user