feat: complete V1 phases 0-7
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.github
|
||||
**/node_modules
|
||||
**/dist
|
||||
coverage
|
||||
.env
|
||||
*.log
|
||||
docs
|
||||
*.png
|
||||
@@ -0,0 +1,43 @@
|
||||
APP_ENV=development
|
||||
API_PORT=3000
|
||||
DATABASE_URL=mysql://lehuo:change-me-local-only@localhost:3306/lehuo
|
||||
TEST_DATABASE_URL=mysql://lehuo:change-me-local-only@localhost:3307/lehuo_test
|
||||
REDIS_URL=redis://localhost:6379
|
||||
MYSQL_DATABASE=lehuo
|
||||
MYSQL_USER=lehuo
|
||||
MYSQL_PASSWORD=change-me-local-only
|
||||
MYSQL_ROOT_PASSWORD=change-root-password-local-only
|
||||
MINIO_ROOT_USER=lehuo-local
|
||||
MINIO_ROOT_PASSWORD=change-minio-password-local-only
|
||||
MINIO_ENDPOINT=http://localhost:9000
|
||||
MINIO_BUCKET=lehuo-local
|
||||
WECHAT_APP_ID=
|
||||
WECHAT_APP_SECRET=
|
||||
WECHAT_TEMPLATE_APPLICATION_SUBMITTED=
|
||||
WECHAT_TEMPLATE_APPLICATION_ACCEPTED=
|
||||
WECHAT_TEMPLATE_SERVICE_ARRANGED=
|
||||
WECHAT_TEMPLATE_SERVICE_COMPLETED=
|
||||
WECHAT_TEMPLATE_FOLLOW_UP_PENDING=
|
||||
WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT=
|
||||
NOTIFICATION_WORKER_ENABLED=false
|
||||
OBJECT_STORAGE_ENDPOINT=http://localhost:9000
|
||||
OBJECT_STORAGE_REGION=auto
|
||||
OBJECT_STORAGE_BUCKET=lehuo-private
|
||||
OBJECT_STORAGE_ACCESS_KEY=
|
||||
OBJECT_STORAGE_SECRET_KEY=
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE=true
|
||||
COS_BUCKET=
|
||||
COS_REGION=
|
||||
COS_SECRET_ID=
|
||||
COS_SECRET_KEY=
|
||||
SESSION_SECRET=
|
||||
SESSION_TOKEN_PEPPER=
|
||||
AUDIT_IP_HASH_KEY=
|
||||
FIELD_ENCRYPTION_KEY=
|
||||
ADMIN_WEB_ORIGIN=http://localhost:5173
|
||||
ADMIN_COOKIE_SECURE=false
|
||||
AUTH_SESSION_TTL_SECONDS=28800
|
||||
WECHAT_SESSION_TTL_SECONDS=7200
|
||||
BOOTSTRAP_ADMIN_USERNAME=
|
||||
BOOTSTRAP_ADMIN_PASSWORD=
|
||||
BOOTSTRAP_ADMIN_DISPLAY_NAME=
|
||||
@@ -0,0 +1,82 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4.8
|
||||
env:
|
||||
MYSQL_DATABASE: lehuo_test
|
||||
MYSQL_USER: lehuo
|
||||
MYSQL_PASSWORD: ci-test-only
|
||||
MYSQL_ROOT_PASSWORD: ci-root-test-only
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -u root -pci-root-test-only --silent"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=10
|
||||
env:
|
||||
APP_ENV: test
|
||||
DATABASE_URL: mysql://lehuo:ci-test-only@127.0.0.1:3306/lehuo_test
|
||||
TEST_DATABASE_URL: mysql://lehuo:ci-test-only@127.0.0.1:3306/lehuo_test
|
||||
SESSION_TOKEN_PEPPER: ci-test-session-token-pepper-2026
|
||||
AUDIT_IP_HASH_KEY: ci-test-audit-ip-hash-key-2026
|
||||
FIELD_ENCRYPTION_KEY: CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws=
|
||||
ADMIN_WEB_ORIGIN: http://localhost:5173
|
||||
ADMIN_COOKIE_SECURE: 'false'
|
||||
WECHAT_APP_ID: ci-test-app-id
|
||||
WECHAT_APP_SECRET: ci-test-app-secret
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.13.1
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate formatting
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Integration tests
|
||||
run: pnpm test:integration
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Validate Docker Compose
|
||||
run: docker compose --env-file .env.example config --quiet
|
||||
|
||||
- name: Dependency audit
|
||||
run: pnpm audit --registry=https://registry.npmjs.org --audit-level high
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Security
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '23 2 * * 1'
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
dependency-and-secret-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11.13.1 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 24, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm audit --audit-level=high
|
||||
- name: Reject committed private keys and common live secrets
|
||||
shell: bash
|
||||
run: |
|
||||
if git grep -IEn 'BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|sk_live_[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}' -- ':!pnpm-lock.yaml'; then
|
||||
echo 'Potential secret detected'; exit 1
|
||||
fi
|
||||
codeql:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: { security-events: write, contents: read }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: github/codeql-action/init@v3
|
||||
with: { languages: javascript-typescript }
|
||||
- uses: github/codeql-action/analyze@v3
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
apps/miniprogram/miniprogram_npm/
|
||||
apps/miniprogram/.miniprogram/
|
||||
apps/api/src/generated/prisma/
|
||||
minio-data/
|
||||
mysql-data/
|
||||
redis-data/
|
||||
@@ -0,0 +1,9 @@
|
||||
**/dist
|
||||
**/node_modules
|
||||
**/miniprogram_npm
|
||||
coverage
|
||||
pnpm-lock.yaml
|
||||
docs
|
||||
乐活公益小程序_Codex开发总文档_V1.0.md
|
||||
docs/ui_reference.png
|
||||
*.png
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# AGENTS.md
|
||||
|
||||
Before coding, read in order:
|
||||
|
||||
1. `docs/00_README_FIRST.md`
|
||||
2. `docs/01_PROJECT_CONSTITUTION.md`
|
||||
3. `docs/10_TASK_STATE.md`
|
||||
4. `docs/11_DECISIONS.md`
|
||||
5. Task-related documents in `docs/`
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not change the approved product scope, technology stack, database, or authentication approach without explicit approval.
|
||||
- Do not remove existing features to simplify implementation or use mocks to claim production functionality is complete.
|
||||
- Do not bypass authorization, resource policies, migrations, tests, audit requirements, or state-machine validation.
|
||||
- Do not log tokens, full mobile numbers, identity documents, detailed addresses, child records, or beneficiary service descriptions.
|
||||
- Do not expose sequential internal IDs; public resources use unpredictable public identifiers.
|
||||
- Every business status change must be auditable, and critical writes must be transactionally consistent.
|
||||
- Every schema change requires a migration; every API change must update shared contracts and API documentation.
|
||||
- Prefer adapters around external providers; business services must not directly depend on vendor SDKs.
|
||||
- Update `docs/10_TASK_STATE.md` after every task and preserve its history.
|
||||
- Update `docs/11_DECISIONS.md` for architecture or product decisions; do not overturn accepted decisions because another approach seems newer.
|
||||
- Run lint, typecheck, unit tests, integration tests, and build before marking work complete.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,37 @@
|
||||
# 乐活公益·暖心服务平台
|
||||
|
||||
V1.0 使用 TypeScript 全栈 monorepo,包含微信小程序、Web 管理后台、模块化单体 API 与共享契约。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 24 LTS
|
||||
- pnpm 11
|
||||
- Docker 29+ 与 Docker Compose
|
||||
|
||||
## 初始化
|
||||
|
||||
1. 复制 `.env.example` 为 `.env`,仅填写本地开发值,禁止提交真实密钥。
|
||||
2. 运行 `pnpm install`。
|
||||
3. 为 `SESSION_TOKEN_PEPPER`、`AUDIT_IP_HASH_KEY` 生成独立随机值,为 `FIELD_ENCRYPTION_KEY` 生成 32 字节随机值并以 Base64 保存;填写微信开发配置和精确的 `ADMIN_WEB_ORIGIN`。
|
||||
4. 运行 `docker compose up -d` 启动 MySQL、Redis、MinIO。
|
||||
5. 运行 `pnpm --filter @lehuo/api db:deploy` 执行 migration。
|
||||
6. 首次部署时通过环境变量提供 `BOOTSTRAP_ADMIN_USERNAME`、`BOOTSTRAP_ADMIN_PASSWORD`、`BOOTSTRAP_ADMIN_DISPLAY_NAME`,运行 `pnpm --filter @lehuo/api bootstrap:admin`;已有后台凭据时命令会拒绝重复初始化。
|
||||
7. 运行 `pnpm --filter @lehuo/api dev` 启动 API,健康检查地址为 `http://localhost:3000/health`,Swagger 地址为 `http://localhost:3000/api/docs`。
|
||||
8. 运行 `pnpm --filter @lehuo/admin-web dev` 启动管理后台。
|
||||
9. 配置小程序 API 地址后,使用微信开发者工具导入 `apps/miniprogram`。
|
||||
|
||||
后台会话使用 HttpOnly Cookie,不存入 localStorage;生产环境必须启用 HTTPS 并设置 `ADMIN_COOKIE_SECURE=true`。密钥和首次管理员密码不得提交到仓库。
|
||||
|
||||
## 质量命令
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test:unit
|
||||
pnpm test:integration
|
||||
pnpm build
|
||||
```
|
||||
|
||||
集成测试只允许连接数据库名为 `lehuo_test` 的 MySQL。可先运行 `docker compose --profile test up -d mysql-test`,再设置 `TEST_DATABASE_URL` 后执行测试。
|
||||
|
||||
项目约束、当前阶段和决策分别见 `docs/01_PROJECT_CONSTITUTION.md`、`docs/10_TASK_STATE.md`、`docs/11_DECISIONS.md`。
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
@@ -0,0 +1,5 @@
|
||||
# 管理后台
|
||||
|
||||
Vue 3 + TypeScript + Vite。Phase 1 至 Phase 5 提供后台账号登录、服务申请与接单、服务对象、工单与记录、回访工作台,以及志愿者审核、公益项目、活动报名、签到和服务时长工作台。凭据由 HttpOnly Cookie 管理,不写入 localStorage;列表手机号默认脱敏,敏感详情查看记录审计。
|
||||
|
||||
复制 `.env.example` 为 `.env.local` 并配置 `VITE_API_BASE_URL`,然后运行 `pnpm --filter @lehuo/admin-web dev`。该地址必须与 API 的 `ADMIN_WEB_ORIGIN` CORS 配置配套。
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="乐活公益暖心服务平台管理后台" />
|
||||
<title>乐活公益管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@lehuo/admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm --filter @lehuo/contracts build && vite build",
|
||||
"dev": "pnpm --filter @lehuo/contracts build && vite",
|
||||
"test:unit": "pnpm --filter @lehuo/contracts build && vitest run",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lehuo/contracts": "workspace:*",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.20",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.3",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-tsc": "^3.0.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { ApiClientError } from './api/api-client';
|
||||
import { ADMIN_APP_TITLE } from './app-info';
|
||||
import { validateLoginForm } from './login-form';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import ServiceApplicationsView from './views/ServiceApplicationsView.vue';
|
||||
import BeneficiariesView from './views/BeneficiariesView.vue';
|
||||
import ServiceCasesView from './views/ServiceCasesView.vue';
|
||||
import FollowUpsView from './views/FollowUpsView.vue';
|
||||
import VolunteersView from './views/VolunteersView.vue';
|
||||
import ProjectsView from './views/ProjectsView.vue';
|
||||
import ActivitiesView from './views/ActivitiesView.vue';
|
||||
import ContentView from './views/ContentView.vue';
|
||||
import NotificationDeliveriesView from './views/NotificationDeliveriesView.vue';
|
||||
import StatisticsView from './views/StatisticsView.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const form = reactive({ username: '', password: '' });
|
||||
const errors = reactive<{ username?: string; password?: string }>({});
|
||||
const submitting = ref(false);
|
||||
const showPassword = ref(false);
|
||||
const message = ref('');
|
||||
const activeView = ref<
|
||||
| 'applications'
|
||||
| 'beneficiaries'
|
||||
| 'cases'
|
||||
| 'follow-ups'
|
||||
| 'volunteers'
|
||||
| 'projects'
|
||||
| 'activities'
|
||||
| 'content'
|
||||
| 'notifications'
|
||||
| 'statistics'
|
||||
>('applications');
|
||||
|
||||
onMounted(() => auth.restore());
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
message.value = '';
|
||||
errors.username = undefined;
|
||||
errors.password = undefined;
|
||||
const validation = validateLoginForm(form.username, form.password);
|
||||
Object.assign(errors, validation);
|
||||
if (Object.keys(validation).length) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await auth.login(form.username.trim().toLowerCase(), form.password);
|
||||
form.password = '';
|
||||
} catch (error) {
|
||||
message.value =
|
||||
error instanceof ApiClientError
|
||||
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
|
||||
: '登录失败,请稍后重试';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
submitting.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
await auth.logout();
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '退出失败,请稍后重试';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="auth.checking" class="app-shell app-shell--centered" aria-live="polite">
|
||||
<section class="welcome-card">
|
||||
<p class="eyebrow">乐活公益 · 暖心服务平台</p>
|
||||
<h1>{{ ADMIN_APP_TITLE }}</h1>
|
||||
<p>正在确认登录状态,请稍候…</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main v-else-if="!auth.authenticated" class="app-shell app-shell--centered">
|
||||
<section class="login-card" aria-labelledby="app-title">
|
||||
<p class="eyebrow">乐活公益 · 暖心服务平台</p>
|
||||
<h1 id="app-title">{{ ADMIN_APP_TITLE }}</h1>
|
||||
<p class="intro">仅限经授权的工作人员使用。登录操作将记录安全审计。</p>
|
||||
<form novalidate @submit.prevent="submit">
|
||||
<label for="username">登录账号</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
:aria-invalid="Boolean(errors.username)"
|
||||
:aria-describedby="errors.username ? 'username-error' : undefined"
|
||||
/>
|
||||
<p v-if="errors.username" id="username-error" class="field-error" role="alert">
|
||||
{{ errors.username }}
|
||||
</p>
|
||||
<label for="password">登录密码</label>
|
||||
<div class="password-field">
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
:aria-invalid="Boolean(errors.password)"
|
||||
:aria-describedby="errors.password ? 'password-error' : undefined"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-toggle"
|
||||
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
{{ showPassword ? '隐藏' : '显示' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" id="password-error" class="field-error" role="alert">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
<p v-if="message" class="form-message" role="alert">{{ message }}</p>
|
||||
<button class="primary-button" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '正在安全登录…' : '登录管理后台' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div v-else class="admin-layout">
|
||||
<aside class="admin-sidebar" aria-label="管理后台导航">
|
||||
<div class="brand-block">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
<div><strong>乐活公益</strong><small>管理平台</small></div>
|
||||
</div>
|
||||
<nav>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'applications' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'applications' ? 'page' : undefined"
|
||||
@click="activeView = 'applications'"
|
||||
>
|
||||
服务申请
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'statistics' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'statistics' ? 'page' : undefined"
|
||||
@click="activeView = 'statistics'"
|
||||
>
|
||||
服务统计
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'content' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'content' ? 'page' : undefined"
|
||||
@click="activeView = 'content'"
|
||||
>
|
||||
内容发布
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'notifications' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'notifications' ? 'page' : undefined"
|
||||
@click="activeView = 'notifications'"
|
||||
>
|
||||
通知投递
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'beneficiaries' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'beneficiaries' ? 'page' : undefined"
|
||||
@click="activeView = 'beneficiaries'"
|
||||
>
|
||||
服务对象
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'cases' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'cases' ? 'page' : undefined"
|
||||
@click="activeView = 'cases'"
|
||||
>
|
||||
服务工单
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'follow-ups' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'follow-ups' ? 'page' : undefined"
|
||||
@click="activeView = 'follow-ups'"
|
||||
>
|
||||
回访工作台
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'volunteers' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'volunteers' ? 'page' : undefined"
|
||||
@click="activeView = 'volunteers'"
|
||||
>
|
||||
志愿者审核
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'projects' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'projects' ? 'page' : undefined"
|
||||
@click="activeView = 'projects'"
|
||||
>
|
||||
公益项目
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { 'nav-item--active': activeView === 'activities' }]"
|
||||
type="button"
|
||||
:aria-current="activeView === 'activities' ? 'page' : undefined"
|
||||
@click="activeView = 'activities'"
|
||||
>
|
||||
活动与报名
|
||||
</button>
|
||||
</nav>
|
||||
<p class="phase-note">Phase 6:内容审核、站内消息与微信订阅通知</p>
|
||||
</aside>
|
||||
<div class="admin-main">
|
||||
<header class="admin-topbar">
|
||||
<div>
|
||||
<strong>{{ auth.user?.displayName ?? '工作人员' }}</strong>
|
||||
<span>已安全登录</span>
|
||||
</div>
|
||||
<button class="text-button" type="button" :disabled="submitting" @click="logout">
|
||||
{{ submitting ? '正在退出…' : '安全退出' }}
|
||||
</button>
|
||||
</header>
|
||||
<p v-if="message" class="global-message" role="alert">{{ message }}</p>
|
||||
<ServiceApplicationsView v-if="activeView === 'applications'" />
|
||||
<BeneficiariesView v-else-if="activeView === 'beneficiaries'" />
|
||||
<ServiceCasesView v-else-if="activeView === 'cases'" />
|
||||
<FollowUpsView v-else-if="activeView === 'follow-ups'" />
|
||||
<VolunteersView v-else-if="activeView === 'volunteers'" />
|
||||
<ProjectsView v-else-if="activeView === 'projects'" />
|
||||
<ActivitiesView v-else-if="activeView === 'activities'" />
|
||||
<ContentView v-else-if="activeView === 'content'" />
|
||||
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
|
||||
<StatisticsView v-else />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ApiErrorResponse, ApiSuccessResponse } from '@lehuo/contracts';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: string,
|
||||
readonly requestId: string | null,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
if (!API_BASE_URL) {
|
||||
throw new ApiClientError('管理后台尚未配置服务地址', 'CONFIGURATION_ERROR', null);
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL(path, API_BASE_URL), {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new ApiClientError('网络连接失败,请检查网络后重试', 'NETWORK_ERROR', null);
|
||||
}
|
||||
const payload = (await response.json()) as ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
if (!response.ok || !payload.success) {
|
||||
const error = payload as ApiErrorResponse;
|
||||
throw new ApiClientError(
|
||||
error.error?.message || '请求失败,请稍后重试',
|
||||
error.error?.code || 'UNKNOWN_ERROR',
|
||||
error.requestId || response.headers.get('x-request-id'),
|
||||
);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type {
|
||||
AcceptServiceApplicationRequest,
|
||||
AcceptServiceApplicationResponse,
|
||||
AssigneeOption,
|
||||
BeneficiaryDetailResponse,
|
||||
BeneficiaryTagOption,
|
||||
ConfirmUploadResponse,
|
||||
CompleteServiceCaseRequest,
|
||||
CompleteServiceCaseResponse,
|
||||
CreateServiceRecordRequest,
|
||||
CreateUploadIntentRequest,
|
||||
CreateUploadIntentResponse,
|
||||
DownloadUrlResponse,
|
||||
PaginatedBeneficiariesResponse,
|
||||
PaginatedServiceCasesResponse,
|
||||
ReplaceCaseAssigneesRequest,
|
||||
ServiceCaseDetailResponse,
|
||||
ServiceCategory,
|
||||
ServiceCaseStatus,
|
||||
ServiceRecordResponse,
|
||||
TransitionServiceApplicationRequest,
|
||||
UpdateBeneficiaryRequest,
|
||||
UpdateServiceRecordRequest,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { apiRequest } from './api-client';
|
||||
|
||||
function queryString(values: Record<string, string | number | boolean | undefined>): string {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') params.set(key, String(value));
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function transitionServiceApplication(
|
||||
publicId: string,
|
||||
input: TransitionServiceApplicationRequest,
|
||||
): Promise<{ status: string }> {
|
||||
return apiRequest(
|
||||
`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}/transition`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function acceptServiceApplication(
|
||||
publicId: string,
|
||||
input: AcceptServiceApplicationRequest,
|
||||
): Promise<AcceptServiceApplicationResponse> {
|
||||
return apiRequest(`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}/accept`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchBeneficiaryTags(): Promise<BeneficiaryTagOption[]> {
|
||||
return apiRequest('/api/v1/admin/beneficiaries/tags');
|
||||
}
|
||||
|
||||
export function fetchBeneficiaries(query: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
primaryCategory?: ServiceCategory;
|
||||
status?: string;
|
||||
keyCareFlag?: boolean;
|
||||
}): Promise<PaginatedBeneficiariesResponse> {
|
||||
return apiRequest(`/api/v1/admin/beneficiaries?${queryString(query)}`);
|
||||
}
|
||||
|
||||
export function fetchBeneficiary(publicId: string): Promise<BeneficiaryDetailResponse> {
|
||||
return apiRequest(`/api/v1/admin/beneficiaries/${encodeURIComponent(publicId)}`);
|
||||
}
|
||||
|
||||
export function updateBeneficiary(
|
||||
publicId: string,
|
||||
input: UpdateBeneficiaryRequest,
|
||||
): Promise<BeneficiaryDetailResponse> {
|
||||
return apiRequest(`/api/v1/admin/beneficiaries/${encodeURIComponent(publicId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchAssigneeOptions(): Promise<AssigneeOption[]> {
|
||||
return apiRequest('/api/v1/admin/service-cases/assignee-options');
|
||||
}
|
||||
|
||||
export function fetchServiceCases(query: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: ServiceCaseStatus;
|
||||
category?: ServiceCategory;
|
||||
caseNo?: string;
|
||||
}): Promise<PaginatedServiceCasesResponse> {
|
||||
return apiRequest(`/api/v1/admin/service-cases?${queryString(query)}`);
|
||||
}
|
||||
|
||||
export function fetchServiceCase(publicId: string): Promise<ServiceCaseDetailResponse> {
|
||||
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}`);
|
||||
}
|
||||
|
||||
export function startServiceCase(publicId: string): Promise<ServiceCaseDetailResponse> {
|
||||
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/start`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
});
|
||||
}
|
||||
|
||||
export function completeServiceCase(
|
||||
publicId: string,
|
||||
input: CompleteServiceCaseRequest,
|
||||
): Promise<CompleteServiceCaseResponse> {
|
||||
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceCaseAssignees(
|
||||
publicId: string,
|
||||
input: ReplaceCaseAssigneesRequest,
|
||||
): Promise<ServiceCaseDetailResponse> {
|
||||
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/assignees`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function createServiceRecord(
|
||||
publicId: string,
|
||||
input: CreateServiceRecordRequest,
|
||||
): Promise<ServiceRecordResponse> {
|
||||
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/records`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateServiceRecord(
|
||||
casePublicId: string,
|
||||
recordPublicId: string,
|
||||
input: UpdateServiceRecordRequest,
|
||||
): Promise<ServiceRecordResponse> {
|
||||
return apiRequest(
|
||||
`/api/v1/admin/service-cases/${encodeURIComponent(casePublicId)}/records/${encodeURIComponent(recordPublicId)}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(input) },
|
||||
);
|
||||
}
|
||||
|
||||
export function createUploadIntent(
|
||||
casePublicId: string,
|
||||
input: CreateUploadIntentRequest,
|
||||
): Promise<CreateUploadIntentResponse> {
|
||||
return apiRequest(
|
||||
`/api/v1/service-cases/${encodeURIComponent(casePublicId)}/files/upload-intents`,
|
||||
{ method: 'POST', body: JSON.stringify(input) },
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmUpload(publicId: string): Promise<ConfirmUploadResponse> {
|
||||
return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/confirm`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchDownloadUrl(publicId: string): Promise<DownloadUrlResponse> {
|
||||
return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/download-url`);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type {
|
||||
ContentPostDetailResponse,
|
||||
CreateContentPostRequest,
|
||||
PaginatedContentPostsResponse,
|
||||
PaginatedNotificationDeliveriesResponse,
|
||||
TransitionContentPostRequest,
|
||||
} from '@lehuo/contracts';
|
||||
import { apiRequest } from './api-client';
|
||||
const query = (v: Record<string, string | number | undefined>) => {
|
||||
const p = new URLSearchParams();
|
||||
Object.entries(v).forEach(([k, x]) => x !== undefined && p.set(k, String(x)));
|
||||
return p.toString();
|
||||
};
|
||||
export const fetchContent = (v: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: string;
|
||||
type?: string;
|
||||
}) => apiRequest<PaginatedContentPostsResponse>(`/api/v1/admin/content?${query(v)}`);
|
||||
export const createContent = (input: CreateContentPostRequest) =>
|
||||
apiRequest<ContentPostDetailResponse>('/api/v1/admin/content', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export const transitionContent = (id: string, input: TransitionContentPostRequest) =>
|
||||
apiRequest<ContentPostDetailResponse>(`/api/v1/admin/content/${id}/transition`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export const fetchDeliveries = (v: { page: number; pageSize: number; status?: string }) =>
|
||||
apiRequest<PaginatedNotificationDeliveriesResponse>(
|
||||
`/api/v1/admin/notification-deliveries?${query(v)}`,
|
||||
);
|
||||
export const retryDelivery = (id: string) =>
|
||||
apiRequest<{ queued: true }>(`/api/v1/admin/notification-deliveries/${id}/retry`, {
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
CompleteFollowUpRequest,
|
||||
CreateFollowUpRequest,
|
||||
FollowUpDetailResponse,
|
||||
FollowUpMutationResponse,
|
||||
FollowUpStatus,
|
||||
FollowUpType,
|
||||
FollowUpWorkbenchSummary,
|
||||
PaginatedFollowUpsResponse,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { apiRequest } from './api-client';
|
||||
|
||||
function queryString(values: Record<string, string | number | boolean | undefined>): string {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') params.set(key, String(value));
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function fetchFollowUpSummary(): Promise<FollowUpWorkbenchSummary> {
|
||||
return apiRequest('/api/v1/admin/follow-ups/summary');
|
||||
}
|
||||
|
||||
export function fetchFollowUps(query: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: FollowUpStatus;
|
||||
followUpType?: FollowUpType;
|
||||
overdue?: boolean;
|
||||
}): Promise<PaginatedFollowUpsResponse> {
|
||||
return apiRequest(`/api/v1/admin/follow-ups?${queryString(query)}`);
|
||||
}
|
||||
|
||||
export function fetchFollowUp(publicId: string): Promise<FollowUpDetailResponse> {
|
||||
return apiRequest(`/api/v1/admin/follow-ups/${encodeURIComponent(publicId)}`);
|
||||
}
|
||||
|
||||
export function createFollowUp(input: CreateFollowUpRequest): Promise<FollowUpMutationResponse> {
|
||||
return apiRequest('/api/v1/admin/follow-ups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function completeFollowUp(
|
||||
publicId: string,
|
||||
input: CompleteFollowUpRequest,
|
||||
): Promise<FollowUpMutationResponse> {
|
||||
return apiRequest(`/api/v1/admin/follow-ups/${encodeURIComponent(publicId)}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type {
|
||||
ActivityDetailResponse,
|
||||
ActivityRegistrationResponse,
|
||||
AssigneeOption,
|
||||
CheckInActivityRegistrationRequest,
|
||||
CreateActivityRequest,
|
||||
CreateProjectRequest,
|
||||
PaginatedActivitiesResponse,
|
||||
PaginatedActivityRegistrationsResponse,
|
||||
PaginatedProjectsResponse,
|
||||
PaginatedVolunteersResponse,
|
||||
ProjectDetailResponse,
|
||||
RecordServiceMinutesRequest,
|
||||
ReviewActivityRegistrationRequest,
|
||||
ReviewVolunteerRequest,
|
||||
TransitionActivityRequest,
|
||||
TransitionProjectRequest,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { apiRequest } from './api-client';
|
||||
|
||||
const query = (values: Record<string, string | number | undefined>) => {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(values).forEach(
|
||||
([key, value]) => value !== undefined && params.set(key, String(value)),
|
||||
);
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const fetchVolunteers = (values: { page: number; pageSize: number; status?: string }) =>
|
||||
apiRequest<PaginatedVolunteersResponse>(`/api/v1/admin/volunteers?${query(values)}`);
|
||||
export const reviewVolunteer = (publicId: string, input: ReviewVolunteerRequest) =>
|
||||
apiRequest(`/api/v1/admin/volunteers/${publicId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
export const fetchProjects = (values: { page: number; pageSize: number; status?: string }) =>
|
||||
apiRequest<PaginatedProjectsResponse>(`/api/v1/admin/projects?${query(values)}`);
|
||||
export const fetchProjectManagerOptions = () =>
|
||||
apiRequest<AssigneeOption[]>('/api/v1/admin/project-manager-options');
|
||||
export const createProject = (input: CreateProjectRequest) =>
|
||||
apiRequest<ProjectDetailResponse>('/api/v1/admin/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export const transitionProject = (publicId: string, input: TransitionProjectRequest) =>
|
||||
apiRequest<ProjectDetailResponse>(`/api/v1/admin/projects/${publicId}/transition`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
export const fetchActivities = (values: { page: number; pageSize: number; status?: string }) =>
|
||||
apiRequest<PaginatedActivitiesResponse>(`/api/v1/admin/activities?${query(values)}`);
|
||||
export const createActivity = (input: CreateActivityRequest) =>
|
||||
apiRequest<ActivityDetailResponse>('/api/v1/admin/activities', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export const transitionActivity = (publicId: string, input: TransitionActivityRequest) =>
|
||||
apiRequest<ActivityDetailResponse>(`/api/v1/admin/activities/${publicId}/transition`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
export const fetchRegistrations = (values: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: string;
|
||||
activityPublicId?: string;
|
||||
}) =>
|
||||
apiRequest<PaginatedActivityRegistrationsResponse>(
|
||||
`/api/v1/admin/activity-registrations?${query(values)}`,
|
||||
);
|
||||
export const reviewRegistration = (publicId: string, input: ReviewActivityRegistrationRequest) =>
|
||||
apiRequest<ActivityRegistrationResponse>(
|
||||
`/api/v1/admin/activity-registrations/${publicId}/review`,
|
||||
{ method: 'POST', body: JSON.stringify(input) },
|
||||
);
|
||||
export const checkInRegistration = (publicId: string, input: CheckInActivityRegistrationRequest) =>
|
||||
apiRequest<ActivityRegistrationResponse>(
|
||||
`/api/v1/admin/activity-registrations/${publicId}/check-in`,
|
||||
{ method: 'POST', body: JSON.stringify(input) },
|
||||
);
|
||||
export const recordServiceMinutes = (publicId: string, input: RecordServiceMinutesRequest) =>
|
||||
apiRequest<ActivityRegistrationResponse>(
|
||||
`/api/v1/admin/activity-registrations/${publicId}/service-minutes`,
|
||||
{ method: 'POST', body: JSON.stringify(input) },
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
AdminServiceApplicationQuery,
|
||||
PaginatedServiceApplicationsResponse,
|
||||
ServiceApplicationDetailResponse,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { apiRequest } from './api-client';
|
||||
|
||||
export function fetchAdminServiceApplications(
|
||||
query: AdminServiceApplicationQuery,
|
||||
): Promise<PaginatedServiceApplicationsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.page) params.set('page', String(query.page));
|
||||
if (query.pageSize) params.set('pageSize', String(query.pageSize));
|
||||
if (query.applicationNo) params.set('applicationNo', query.applicationNo);
|
||||
if (query.serviceCategory) params.set('serviceCategory', query.serviceCategory);
|
||||
if (query.status) params.set('status', query.status);
|
||||
return apiRequest(`/api/v1/admin/service-applications?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function fetchAdminServiceApplication(
|
||||
publicId: string,
|
||||
): Promise<ServiceApplicationDetailResponse> {
|
||||
return apiRequest(`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}`);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { StatisticsOverview } from '@lehuo/contracts';
|
||||
import { apiRequest } from './api-client';
|
||||
export const fetchStatistics = (startDate?: string, endDate?: string) => {
|
||||
const p = new URLSearchParams();
|
||||
if (startDate) p.set('startDate', startDate);
|
||||
if (endDate) p.set('endDate', endDate);
|
||||
return apiRequest<StatisticsOverview>(`/api/v1/admin/statistics/overview?${p.toString()}`);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ADMIN_APP_TITLE } from './app-info';
|
||||
|
||||
describe('admin app information', () => {
|
||||
it('uses the approved public-service brand', () => {
|
||||
expect(ADMIN_APP_TITLE).toContain('乐活公益');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ADMIN_APP_TITLE = '乐活公益管理后台';
|
||||
export const PHASE_ZERO_MESSAGE = '工程基线已就绪,业务功能将在后续阶段实现。';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatChinaDateTime, statusTone } from './application-format';
|
||||
|
||||
describe('application formatting', () => {
|
||||
it('formats API timestamps for China display', () => {
|
||||
expect(formatChinaDateTime('2026-08-11T02:30:00.000Z')).toContain('2026-08-11');
|
||||
expect(formatChinaDateTime('invalid')).toBe('时间待确认');
|
||||
});
|
||||
|
||||
it('uses text-compatible semantic status tones', () => {
|
||||
expect(statusTone('SUBMITTED')).toBe('info');
|
||||
expect(statusTone('CLOSED')).toBe('success');
|
||||
expect(statusTone('REJECTED')).toBe('danger');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ServiceApplicationStatus } from '@lehuo/contracts';
|
||||
|
||||
export function formatChinaDateTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '时间待确认';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.format(date)
|
||||
.replaceAll('/', '-');
|
||||
}
|
||||
|
||||
export function statusTone(status: ServiceApplicationStatus): string {
|
||||
if (['REJECTED', 'OUT_OF_SCOPE', 'CANCELLED'].includes(status)) return 'danger';
|
||||
if (['CLOSED', 'SERVICE_COMPLETED'].includes(status)) return 'success';
|
||||
if (['SUSPENDED', 'FOLLOW_UP_PENDING'].includes(status)) return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { validateLoginForm } from './login-form';
|
||||
|
||||
describe('validateLoginForm', () => {
|
||||
it('accepts a valid staff login form', () => {
|
||||
expect(validateLoginForm('staff.admin', 'correct horse battery staple')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns field-level recovery guidance', () => {
|
||||
expect(validateLoginForm('?', 'short')).toEqual({
|
||||
username: '请输入有效的登录账号',
|
||||
password: '密码长度应为 12 至 128 个字符',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface LoginFormErrors {
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export function validateLoginForm(username: string, password: string): LoginFormErrors {
|
||||
const errors: LoginFormErrors = {};
|
||||
if (!/^[a-z0-9._-]{3,64}$/.test(username.trim().toLowerCase())) {
|
||||
errors.username = '请输入有效的登录账号';
|
||||
}
|
||||
if (password.length < 12 || password.length > 128) {
|
||||
errors.password = '密码长度应为 12 至 128 个字符';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import App from './App.vue';
|
||||
import './styles.css';
|
||||
|
||||
createApp(App).use(createPinia()).mount('#app');
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import type { AdminLoginResponse, AuthUser, CurrentUserResponse } from '@lehuo/contracts';
|
||||
|
||||
import { apiRequest } from '../api/api-client';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<AuthUser | null>(null);
|
||||
const checking = ref(true);
|
||||
const authenticated = computed(() => user.value !== null);
|
||||
|
||||
async function restore(): Promise<void> {
|
||||
checking.value = true;
|
||||
try {
|
||||
const result = await apiRequest<CurrentUserResponse>('/api/v1/admin/auth/me');
|
||||
user.value = result.user;
|
||||
} catch {
|
||||
user.value = null;
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username: string, password: string): Promise<void> {
|
||||
const result = await apiRequest<AdminLoginResponse>('/api/v1/admin/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
user.value = result.user;
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await apiRequest('/api/v1/admin/auth/logout', { method: 'POST', body: '{}' });
|
||||
user.value = null;
|
||||
}
|
||||
|
||||
return { user, checking, authenticated, restore, login, logout };
|
||||
});
|
||||
@@ -0,0 +1,803 @@
|
||||
:root {
|
||||
--color-primary: #ff6b35;
|
||||
--color-primary-hover: #e95a28;
|
||||
--color-primary-soft: #fff1eb;
|
||||
--color-text: #1f2329;
|
||||
--color-text-secondary: #646a73;
|
||||
--color-danger: #b42318;
|
||||
--color-success: #237a2b;
|
||||
--color-warning: #8a5a00;
|
||||
--color-info: #145ea8;
|
||||
--color-surface: #fff;
|
||||
--color-background: #f5f5f5;
|
||||
--color-border: #d9dce1;
|
||||
--color-sidebar: #263342;
|
||||
--focus-ring: 0 0 0 3px rgb(255 107 53 / 28%);
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.app-shell--centered {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.welcome-card,
|
||||
.login-card {
|
||||
width: min(560px, 100%);
|
||||
padding: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 6%);
|
||||
}
|
||||
.welcome-card h1,
|
||||
.login-card h1 {
|
||||
margin: 8px 0 16px;
|
||||
font-size: clamp(24px, 4vw, 32px);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.welcome-card p,
|
||||
.login-card p {
|
||||
line-height: 1.7;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--color-primary-hover);
|
||||
font-weight: 600;
|
||||
}
|
||||
.intro {
|
||||
margin-bottom: 24px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.login-card form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.login-card label {
|
||||
margin-top: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.login-card input:hover {
|
||||
border-color: #aeb3bb;
|
||||
}
|
||||
input[aria-invalid='true'] {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.password-field {
|
||||
position: relative;
|
||||
}
|
||||
.password-field input {
|
||||
padding-right: 72px;
|
||||
}
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 64px;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
color: var(--color-primary-hover);
|
||||
background: transparent;
|
||||
}
|
||||
.field-error,
|
||||
.form-message,
|
||||
.global-message {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
min-height: 48px;
|
||||
margin-top: 16px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
border-color 180ms ease;
|
||||
}
|
||||
.primary-button {
|
||||
border: 1px solid var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
.primary-button:hover:not(:disabled) {
|
||||
border-color: var(--color-primary-hover);
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
.secondary-button {
|
||||
border: 1px solid var(--color-primary);
|
||||
color: var(--color-primary-hover);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
.compact-button {
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 8px 18px;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
background: var(--color-background);
|
||||
}
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
padding: 24px 16px;
|
||||
flex-direction: column;
|
||||
color: #fff;
|
||||
background: var(--color-sidebar);
|
||||
}
|
||||
.brand-block {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.brand-block strong,
|
||||
.brand-block small {
|
||||
display: block;
|
||||
}
|
||||
.brand-block strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
.brand-block small {
|
||||
margin-top: 4px;
|
||||
color: #bfc8d2;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 8px solid var(--color-primary);
|
||||
border-top-color: #ffb13b;
|
||||
border-radius: 50% 50% 50% 16px;
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
.admin-sidebar nav {
|
||||
margin-top: 28px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
color: #e8edf2;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.nav-item--active {
|
||||
color: #fff;
|
||||
background: rgb(255 107 53 / 22%);
|
||||
box-shadow: inset 3px 0 var(--color-primary);
|
||||
}
|
||||
.admin-sidebar nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.link-button--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.phase-note {
|
||||
margin-top: auto;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
color: #cbd3dc;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.admin-main {
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-topbar {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
padding: 0 28px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: #fff;
|
||||
}
|
||||
.admin-topbar strong,
|
||||
.admin-topbar span {
|
||||
display: block;
|
||||
}
|
||||
.admin-topbar span {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.text-button,
|
||||
.back-button,
|
||||
.link-button {
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--color-info);
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
.text-button:hover,
|
||||
.back-button:hover,
|
||||
.link-button:hover {
|
||||
background: #eaf4ff;
|
||||
}
|
||||
.admin-main > .global-message {
|
||||
margin: 12px 28px 0;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
width: min(1440px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
margin-bottom: 22px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.page-header p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.back-button {
|
||||
margin: -8px 0 8px -12px;
|
||||
}
|
||||
.result-count {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
background: #fff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: grid;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
grid-template-columns: minmax(180px, 1.2fr) minmax(150px, 1fr) minmax(150px, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
.filter-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.filter-field input,
|
||||
.filter-field select {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 9px;
|
||||
color: var(--color-text);
|
||||
background: #fff;
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.table-panel,
|
||||
.empty-panel {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 3px 12px rgb(0 0 0 / 4%);
|
||||
}
|
||||
.panel {
|
||||
padding: 24px;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0 0 20px;
|
||||
font-size: 19px;
|
||||
}
|
||||
.panel h3 {
|
||||
margin: 24px 0 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.empty-panel {
|
||||
display: grid;
|
||||
min-height: 280px;
|
||||
padding: 32px;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-panel h2 {
|
||||
margin: 0;
|
||||
font-size: 21px;
|
||||
}
|
||||
.empty-panel p {
|
||||
max-width: 520px;
|
||||
margin: 12px 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 15px 16px;
|
||||
border-bottom: 1px solid #ebecef;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
th {
|
||||
color: #4e545d;
|
||||
background: #fafafa;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
font-size: 14px;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: #fffaf7;
|
||||
}
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
padding: 10px 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 40px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text);
|
||||
background: #fff;
|
||||
}
|
||||
.pagination span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-flex;
|
||||
min-height: 28px;
|
||||
padding: 4px 9px;
|
||||
align-items: center;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-tag--info {
|
||||
color: var(--color-info);
|
||||
background: #eaf4ff;
|
||||
}
|
||||
.status-tag--success {
|
||||
color: var(--color-success);
|
||||
background: #edf8ee;
|
||||
}
|
||||
.status-tag--warning {
|
||||
color: var(--color-warning);
|
||||
background: #fff6d8;
|
||||
}
|
||||
.status-tag--danger {
|
||||
color: var(--color-danger);
|
||||
background: #fff0ee;
|
||||
}
|
||||
|
||||
.audit-notice {
|
||||
margin-bottom: 20px;
|
||||
padding: 14px 16px;
|
||||
border-left: 4px solid #d18a00;
|
||||
border-radius: 8px;
|
||||
color: #604500;
|
||||
background: #fff7de;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
margin-bottom: 20px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.detail-list {
|
||||
margin: 0;
|
||||
}
|
||||
.detail-list > div {
|
||||
display: grid;
|
||||
min-height: 48px;
|
||||
padding: 11px 0;
|
||||
grid-template-columns: 120px 1fr;
|
||||
border-top: 1px solid #ebecef;
|
||||
}
|
||||
.detail-list dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.detail-list dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sensitive-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
.description-text {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border-radius: 9px;
|
||||
background: #fafafa;
|
||||
line-height: 1.75;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.timeline-panel {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.workflow-panel {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.workflow-panel > p,
|
||||
.consent-note {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px 20px;
|
||||
}
|
||||
.form-grid label {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-grid input:not([type='checkbox']),
|
||||
.form-grid select,
|
||||
.form-grid textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 9px;
|
||||
color: var(--color-text);
|
||||
background: #fff;
|
||||
}
|
||||
.form-grid select[multiple] {
|
||||
min-height: 132px;
|
||||
}
|
||||
.form-grid textarea {
|
||||
min-height: 112px;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.form-span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
padding: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.checkbox-group legend {
|
||||
padding: 0 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.contact-summary {
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.contact-summary p {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.record-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
margin-bottom: 20px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.summary-card {
|
||||
display: grid;
|
||||
min-height: 126px;
|
||||
padding: 18px;
|
||||
align-content: space-between;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
color: var(--color-text);
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
box-shadow: 0 3px 12px rgb(0 0 0 / 4%);
|
||||
}
|
||||
button.summary-card:hover {
|
||||
border-color: var(--color-danger);
|
||||
background: #fff8f7;
|
||||
}
|
||||
.summary-card strong {
|
||||
font-size: 32px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.summary-card small,
|
||||
.block-note {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.summary-card--danger strong,
|
||||
.danger-text {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.block-note {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.consent-check {
|
||||
display: flex !important;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.record-card {
|
||||
padding: 18px 0;
|
||||
border-top: 1px solid #ebecef;
|
||||
}
|
||||
.record-card header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.record-card time {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.record-card p {
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.admin-timeline {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.admin-timeline li {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 104px;
|
||||
grid-template-columns: 24px 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.admin-timeline li:not(:last-child)::before {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
bottom: -4px;
|
||||
left: 7px;
|
||||
width: 2px;
|
||||
content: '';
|
||||
background: #ffd3c1;
|
||||
}
|
||||
.timeline-marker {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 3px;
|
||||
border: 4px solid #ffe4d8;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
.admin-timeline strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.admin-timeline p {
|
||||
margin: 6px 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.admin-timeline time {
|
||||
color: #767d87;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.table-skeleton {
|
||||
height: 50px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(90deg, #f0f1f3 25%, #fafafa 50%, #f0f1f3 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.4s ease-in-out infinite;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-layout {
|
||||
grid-template-columns: 190px minmax(0, 1fr);
|
||||
}
|
||||
.filter-panel {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.filter-actions {
|
||||
align-self: end;
|
||||
}
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.form-span {
|
||||
grid-column: auto;
|
||||
}
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.admin-layout {
|
||||
display: block;
|
||||
}
|
||||
.admin-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.brand-block {
|
||||
min-height: 52px;
|
||||
}
|
||||
.admin-sidebar nav,
|
||||
.phase-note {
|
||||
display: none;
|
||||
}
|
||||
.admin-topbar {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.content-area {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.filter-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.filter-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.filter-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
.detail-list > div {
|
||||
grid-template-columns: 100px 1fr;
|
||||
}
|
||||
.welcome-card,
|
||||
.login-card {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
ACTIVITY_STATUSES,
|
||||
ACTIVITY_STATUS_LABELS,
|
||||
type ActivityListItem,
|
||||
type ActivityRegistrationItem,
|
||||
type ActivityStatus,
|
||||
type ProjectListItem,
|
||||
} from '@lehuo/contracts';
|
||||
import { ApiClientError } from '../api/api-client';
|
||||
import {
|
||||
checkInRegistration,
|
||||
createActivity,
|
||||
fetchActivities,
|
||||
fetchProjects,
|
||||
fetchRegistrations,
|
||||
recordServiceMinutes,
|
||||
reviewRegistration,
|
||||
transitionActivity,
|
||||
} from '../api/participation';
|
||||
|
||||
const activities = ref<ActivityListItem[]>([]),
|
||||
projects = ref<ProjectListItem[]>([]),
|
||||
registrations = ref<ActivityRegistrationItem[]>([]);
|
||||
const loading = ref(true),
|
||||
busy = ref(false),
|
||||
showCreate = ref(false),
|
||||
message = ref('');
|
||||
const page = ref(1),
|
||||
total = ref(0),
|
||||
pageSize = 20,
|
||||
totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
const filters = reactive<{ status: ActivityStatus | '' }>({ status: '' });
|
||||
const form = reactive({
|
||||
projectPublicId: '',
|
||||
title: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
startAt: '',
|
||||
endAt: '',
|
||||
registrationDeadline: '',
|
||||
locationText: '',
|
||||
capacity: undefined as number | undefined,
|
||||
});
|
||||
const errorText = (error: unknown) =>
|
||||
error instanceof ApiClientError
|
||||
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
|
||||
: '操作失败,请稍后重试';
|
||||
async function load(target = 1) {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const [list, regs] = await Promise.all([
|
||||
fetchActivities({
|
||||
page: target,
|
||||
pageSize,
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
}),
|
||||
fetchRegistrations({ page: 1, pageSize: 100 }),
|
||||
]);
|
||||
activities.value = list.items;
|
||||
total.value = list.total;
|
||||
page.value = list.page;
|
||||
registrations.value = regs.items;
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (!form.startAt || !form.endAt || !form.registrationDeadline) {
|
||||
message.value = '请完整填写活动与报名时间。';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
await createActivity({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
...(form.projectPublicId ? { projectPublicId: form.projectPublicId } : {}),
|
||||
title: form.title,
|
||||
summary: form.summary,
|
||||
content: form.content,
|
||||
startAt: new Date(form.startAt).toISOString(),
|
||||
endAt: new Date(form.endAt).toISOString(),
|
||||
registrationDeadline: new Date(form.registrationDeadline).toISOString(),
|
||||
locationText: form.locationText,
|
||||
...(form.capacity ? { capacity: form.capacity } : {}),
|
||||
});
|
||||
showCreate.value = false;
|
||||
Object.assign(form, {
|
||||
projectPublicId: '',
|
||||
title: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
startAt: '',
|
||||
endAt: '',
|
||||
registrationDeadline: '',
|
||||
locationText: '',
|
||||
capacity: undefined,
|
||||
});
|
||||
message.value = '活动草稿已创建。';
|
||||
await load(1);
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
function nextStatus(status: ActivityStatus): Exclude<ActivityStatus, 'DRAFT'> | null {
|
||||
return (
|
||||
(
|
||||
{
|
||||
DRAFT: 'PUBLISHED',
|
||||
PUBLISHED: 'REGISTRATION_CLOSED',
|
||||
REGISTRATION_CLOSED: 'IN_PROGRESS',
|
||||
IN_PROGRESS: 'COMPLETED',
|
||||
} as Partial<Record<ActivityStatus, Exclude<ActivityStatus, 'DRAFT'>>>
|
||||
)[status] ?? null
|
||||
);
|
||||
}
|
||||
async function advance(item: ActivityListItem) {
|
||||
const targetStatus = nextStatus(item.status);
|
||||
if (
|
||||
!targetStatus ||
|
||||
!window.confirm(`确认将活动推进至“${ACTIVITY_STATUS_LABELS[targetStatus]}”?`)
|
||||
)
|
||||
return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await transitionActivity(item.publicId, { clientRequestId: crypto.randomUUID(), targetStatus });
|
||||
message.value = '活动状态已更新并记录审计。';
|
||||
await load(page.value);
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function review(item: ActivityRegistrationItem, targetStatus: 'APPROVED' | 'REJECTED') {
|
||||
busy.value = true;
|
||||
try {
|
||||
await reviewRegistration(item.publicId, {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
targetStatus,
|
||||
note: targetStatus === 'APPROVED' ? '报名审核通过' : '报名不符合本次活动要求',
|
||||
});
|
||||
await load(page.value);
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function checkIn(item: ActivityRegistrationItem, targetStatus: 'CHECKED_IN' | 'ABSENT') {
|
||||
busy.value = true;
|
||||
try {
|
||||
await checkInRegistration(item.publicId, {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
targetStatus,
|
||||
});
|
||||
await load(page.value);
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function minutes(item: ActivityRegistrationItem) {
|
||||
const value = Number(window.prompt('请输入核定服务分钟数', String(item.serviceMinutes ?? 60)));
|
||||
if (!Number.isInteger(value) || value <= 0) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await recordServiceMinutes(item.publicId, {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
serviceMinutes: value,
|
||||
});
|
||||
await load(page.value);
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
load(),
|
||||
fetchProjects({ page: 1, pageSize: 100 }).then((result) => {
|
||||
projects.value = result.items.filter((item) => item.status !== 'COMPLETED');
|
||||
}),
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<main class="content-area" tabindex="-1">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>公益活动与报名</h1>
|
||||
<p>发布活动、控制报名容量,并记录审核、签到和服务时长事实。</p>
|
||||
</div>
|
||||
<button class="primary-button compact-button" @click="showCreate = !showCreate">
|
||||
{{ showCreate ? '取消新建' : '新建活动' }}
|
||||
</button>
|
||||
</header>
|
||||
<form v-if="showCreate" class="panel form-grid" @submit.prevent="save">
|
||||
<label
|
||||
>所属项目<select v-model="form.projectPublicId">
|
||||
<option value="">独立活动</option>
|
||||
<option v-for="project in projects" :key="project.publicId" :value="project.publicId">
|
||||
{{ project.name }}
|
||||
</option>
|
||||
</select></label
|
||||
><label
|
||||
>活动标题<input v-model.trim="form.title" required minlength="2" maxlength="200" /></label
|
||||
><label
|
||||
>报名截止<input v-model="form.registrationDeadline" type="datetime-local" required /></label
|
||||
><label>人数上限<input v-model.number="form.capacity" type="number" min="1" /></label
|
||||
><label>开始时间<input v-model="form.startAt" type="datetime-local" required /></label
|
||||
><label>结束时间<input v-model="form.endAt" type="datetime-local" required /></label
|
||||
><label class="form-span"
|
||||
>活动地点<input
|
||||
v-model.trim="form.locationText"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="300" /></label
|
||||
><label class="form-span"
|
||||
>活动摘要<textarea
|
||||
v-model.trim="form.summary"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="500"
|
||||
></textarea></label
|
||||
><label class="form-span"
|
||||
>活动内容<textarea
|
||||
v-model.trim="form.content"
|
||||
required
|
||||
minlength="20"
|
||||
maxlength="20000"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="form-actions form-span">
|
||||
<button class="primary-button compact-button" :disabled="busy">
|
||||
{{ busy ? '保存中…' : '保存草稿' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="filter-panel" @submit.prevent="load(1)">
|
||||
<div class="filter-field">
|
||||
<label for="activity-status">活动状态</label
|
||||
><select id="activity-status" v-model="filters.status">
|
||||
<option value="">全部状态</option>
|
||||
<option v-for="status in ACTIVITY_STATUSES" :key="status" :value="status">
|
||||
{{ ACTIVITY_STATUS_LABELS[status] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="primary-button compact-button">查询</button>
|
||||
</form>
|
||||
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
||||
<section v-if="loading" class="panel">正在加载活动与报名…</section>
|
||||
<template v-else
|
||||
><section class="table-panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>活动</th>
|
||||
<th>时间/地点</th>
|
||||
<th>报名</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in activities" :key="item.publicId">
|
||||
<td>
|
||||
<strong>{{ item.title }}</strong
|
||||
><br />{{ item.projectName ?? '独立活动' }}
|
||||
</td>
|
||||
<td>
|
||||
{{ new Date(item.startAt).toLocaleString('zh-CN') }}<br />{{ item.locationText }}
|
||||
</td>
|
||||
<td>{{ item.activeRegistrationCount }} / {{ item.capacity ?? '不限' }}</td>
|
||||
<td>{{ item.statusLabel }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="nextStatus(item.status)"
|
||||
class="link-button"
|
||||
:disabled="busy"
|
||||
@click="advance(item)"
|
||||
>
|
||||
推进至{{ ACTIVITY_STATUS_LABELS[nextStatus(item.status)!] }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
|
||||
><span>第 {{ page }} / {{ totalPages }} 页</span
|
||||
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>报名与现场记录</h2>
|
||||
<p v-if="!registrations.length">暂无报名记录。</p>
|
||||
<div v-else class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>活动</th>
|
||||
<th>志愿者</th>
|
||||
<th>报名/签到</th>
|
||||
<th>服务时长</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in registrations" :key="item.publicId">
|
||||
<td>{{ item.activityTitle }}</td>
|
||||
<td>{{ item.volunteerName }}<br />{{ item.mobileMasked }}</td>
|
||||
<td>{{ item.statusLabel }} / {{ item.checkInStatusLabel }}</td>
|
||||
<td>
|
||||
{{ item.serviceMinutes === null ? '待核定' : `${item.serviceMinutes} 分钟` }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="inline-actions">
|
||||
<button
|
||||
v-if="item.status === 'PENDING'"
|
||||
class="link-button"
|
||||
@click="review(item, 'APPROVED')"
|
||||
>
|
||||
通过</button
|
||||
><button
|
||||
v-if="item.status === 'PENDING'"
|
||||
class="link-button link-button--danger"
|
||||
@click="review(item, 'REJECTED')"
|
||||
>
|
||||
拒绝</button
|
||||
><button
|
||||
v-if="item.status === 'APPROVED' && item.checkInStatus === 'NOT_CHECKED_IN'"
|
||||
class="link-button"
|
||||
@click="checkIn(item, 'CHECKED_IN')"
|
||||
>
|
||||
签到</button
|
||||
><button
|
||||
v-if="item.status === 'APPROVED' && item.checkInStatus === 'NOT_CHECKED_IN'"
|
||||
class="link-button"
|
||||
@click="checkIn(item, 'ABSENT')"
|
||||
>
|
||||
缺席</button
|
||||
><button
|
||||
v-if="item.checkInStatus === 'CHECKED_IN'"
|
||||
class="link-button"
|
||||
@click="minutes(item)"
|
||||
>
|
||||
核定时长
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section></template
|
||||
>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
BENEFICIARY_STATUSES,
|
||||
SERVICE_CATEGORIES,
|
||||
SERVICE_CATEGORY_LABELS,
|
||||
type BeneficiaryDetail,
|
||||
type BeneficiaryListItem,
|
||||
type BeneficiaryStatus,
|
||||
type ServiceCategory,
|
||||
type UpdateBeneficiaryRequest,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { ApiClientError } from '../api/api-client';
|
||||
import { fetchBeneficiaries, fetchBeneficiary, updateBeneficiary } from '../api/case-management';
|
||||
import { formatChinaDateTime } from '../application-format';
|
||||
|
||||
const items = ref<BeneficiaryListItem[]>([]);
|
||||
const selected = ref<BeneficiaryDetail | null>(null);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const message = ref('');
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const pageSize = 20;
|
||||
const filters = reactive<{
|
||||
primaryCategory: ServiceCategory | '';
|
||||
status: BeneficiaryStatus | '';
|
||||
keyCare: '' | 'true' | 'false';
|
||||
}>({
|
||||
primaryCategory: '',
|
||||
status: '',
|
||||
keyCare: '',
|
||||
});
|
||||
const form = reactive<UpdateBeneficiaryRequest>({
|
||||
displayName: '',
|
||||
regionCode: '',
|
||||
keyCareFlag: false,
|
||||
isMinor: false,
|
||||
contacts: [],
|
||||
tagCodes: [],
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
|
||||
function showError(error: unknown): void {
|
||||
message.value =
|
||||
error instanceof ApiClientError
|
||||
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
|
||||
: '操作失败,请稍后重试';
|
||||
}
|
||||
|
||||
async function load(targetPage = 1): Promise<void> {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
selected.value = null;
|
||||
try {
|
||||
const result = await fetchBeneficiaries({
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
...(filters.primaryCategory ? { primaryCategory: filters.primaryCategory } : {}),
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.keyCare ? { keyCareFlag: filters.keyCare === 'true' } : {}),
|
||||
});
|
||||
items.value = result.items;
|
||||
total.value = result.total;
|
||||
page.value = result.page;
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function open(publicId: string): Promise<void> {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await fetchBeneficiary(publicId);
|
||||
selected.value = result.beneficiary;
|
||||
Object.assign(form, {
|
||||
displayName: result.beneficiary.displayName,
|
||||
gender: result.beneficiary.gender ?? undefined,
|
||||
birthYear: result.beneficiary.birthYear ?? undefined,
|
||||
mobile: result.beneficiary.mobile ?? undefined,
|
||||
regionCode: result.beneficiary.regionCode,
|
||||
addressDetail: result.beneficiary.addressDetail ?? undefined,
|
||||
keyCareFlag: result.beneficiary.keyCareFlag,
|
||||
isMinor: result.beneficiary.isMinor,
|
||||
notes: result.beneficiary.notes ?? undefined,
|
||||
contacts: result.beneficiary.contacts.map(
|
||||
({ name, relation, mobile, isGuardian, isEmergencyContact }) => ({
|
||||
name,
|
||||
relation,
|
||||
mobile,
|
||||
isGuardian,
|
||||
isEmergencyContact,
|
||||
}),
|
||||
),
|
||||
tagCodes: result.beneficiary.tags.map((tag) => tag.code),
|
||||
status: result.beneficiary.status,
|
||||
...(result.beneficiary.isMinor ? { guardianConsentConfirmed: true as const } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!selected.value) return;
|
||||
saving.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
selected.value = (await updateBeneficiary(selected.value.publicId, form)).beneficiary;
|
||||
message.value = '档案已安全保存并记录审计。';
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="content-area" tabindex="-1">
|
||||
<template v-if="selected">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<button class="back-button" type="button" @click="selected = null">
|
||||
← 返回服务对象列表
|
||||
</button>
|
||||
<h1>服务对象档案</h1>
|
||||
<p>查看和维护提供服务所需的最少信息。</p>
|
||||
</div>
|
||||
<span class="status-tag status-tag--info">{{ selected.serviceCaseCount }} 个工单</span>
|
||||
</header>
|
||||
<div class="audit-notice">档案含敏感信息,本次查看和修改均会记录安全审计。</div>
|
||||
<form class="panel form-grid" @submit.prevent="save">
|
||||
<label>姓名<input v-model.trim="form.displayName" required maxlength="50" /></label>
|
||||
<label
|
||||
>手机号<input v-model.trim="form.mobile" inputmode="numeric" pattern="1[3-9][0-9]{9}"
|
||||
/></label>
|
||||
<label>所在地区<input v-model.trim="form.regionCode" required maxlength="100" /></label>
|
||||
<label
|
||||
>出生年份<input v-model.number="form.birthYear" type="number" min="1900" max="2200"
|
||||
/></label>
|
||||
<label
|
||||
>档案状态<select v-model="form.status">
|
||||
<option v-for="status in BENEFICIARY_STATUSES" :key="status" :value="status">
|
||||
{{ status === 'ACTIVE' ? '正常' : status === 'INACTIVE' ? '暂停' : '归档' }}
|
||||
</option>
|
||||
</select></label
|
||||
>
|
||||
<label>详细地址<input v-model.trim="form.addressDetail" maxlength="300" /></label>
|
||||
<label class="form-span"
|
||||
><input v-model="form.keyCareFlag" type="checkbox" /> 重点关怀对象</label
|
||||
>
|
||||
<label class="form-span"
|
||||
>备注<textarea v-model.trim="form.notes" maxlength="1000"></textarea>
|
||||
</label>
|
||||
<div class="form-span contact-summary">
|
||||
<strong>联系人</strong>
|
||||
<p v-if="!form.contacts.length">暂无联系人</p>
|
||||
<p v-for="(contact, index) in form.contacts" :key="index">
|
||||
{{ contact.name }}({{ contact.relation }}) · {{ contact.mobile
|
||||
}}<span v-if="contact.isGuardian"> · 监护人</span>
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="message" class="global-message form-span" role="status">{{ message }}</p>
|
||||
<div class="form-actions form-span">
|
||||
<button class="primary-button compact-button" :disabled="saving">
|
||||
{{ saving ? '保存中…' : '保存档案' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>服务对象</h1>
|
||||
<p>按服务类别、状态和重点关怀标记查找档案。</p>
|
||||
</div>
|
||||
<span class="result-count">共 {{ total }} 人</span>
|
||||
</header>
|
||||
<form class="filter-panel" @submit.prevent="load(1)">
|
||||
<div class="filter-field">
|
||||
<label>服务类型</label
|
||||
><select v-model="filters.primaryCategory">
|
||||
<option value="">全部类型</option>
|
||||
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
|
||||
{{ SERVICE_CATEGORY_LABELS[category] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>档案状态</label
|
||||
><select v-model="filters.status">
|
||||
<option value="">全部状态</option>
|
||||
<option value="ACTIVE">正常</option>
|
||||
<option value="INACTIVE">暂停</option>
|
||||
<option value="ARCHIVED">归档</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>重点关怀</label
|
||||
><select v-model="filters.keyCare">
|
||||
<option value="">全部</option>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="primary-button compact-button">查询</button>
|
||||
</form>
|
||||
<section v-if="loading" class="panel" aria-live="polite">
|
||||
<div v-for="n in 6" :key="n" class="table-skeleton"></div>
|
||||
</section>
|
||||
<section v-else-if="message" class="empty-panel" role="alert">
|
||||
<h2>服务对象加载失败</h2>
|
||||
<p>{{ message }}</p>
|
||||
<button class="secondary-button compact-button" @click="load(page)">重试</button>
|
||||
</section>
|
||||
<section v-else-if="!items.length" class="empty-panel">
|
||||
<h2>暂无符合条件的服务对象</h2>
|
||||
<p>档案将在申请通过评估后建立。</p>
|
||||
</section>
|
||||
<section v-else class="table-panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>姓名</th>
|
||||
<th>手机号</th>
|
||||
<th>地区</th>
|
||||
<th>服务类型</th>
|
||||
<th>标签</th>
|
||||
<th>建立时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.publicId">
|
||||
<td>
|
||||
{{ item.displayName }}
|
||||
<span v-if="item.keyCareFlag" class="status-tag status-tag--warning">重点</span>
|
||||
</td>
|
||||
<td>{{ item.mobileMasked ?? '未留存' }}</td>
|
||||
<td>{{ item.regionCode }}</td>
|
||||
<td>{{ SERVICE_CATEGORY_LABELS[item.primaryCategory] }}</td>
|
||||
<td>{{ item.tags.map((tag) => tag.name).join('、') || '—' }}</td>
|
||||
<td>{{ formatChinaDateTime(item.createdAt) }}</td>
|
||||
<td><button class="link-button" @click="open(item.publicId)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
|
||||
><span>第 {{ page }} / {{ totalPages }} 页</span
|
||||
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
CONTENT_POST_STATUSES,
|
||||
CONTENT_POST_STATUS_LABELS,
|
||||
CONTENT_POST_TYPES,
|
||||
CONTENT_POST_TYPE_LABELS,
|
||||
type ContentPostListItem,
|
||||
type ContentPostStatus,
|
||||
type ContentPostType,
|
||||
} from '@lehuo/contracts';
|
||||
import { ApiClientError } from '../api/api-client';
|
||||
import { createContent, fetchContent, transitionContent } from '../api/content-notifications';
|
||||
const items = ref<ContentPostListItem[]>([]),
|
||||
loading = ref(true),
|
||||
busy = ref(false),
|
||||
showCreate = ref(false),
|
||||
message = ref(''),
|
||||
page = ref(1),
|
||||
total = ref(0),
|
||||
pageSize = 20,
|
||||
totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
const filters = reactive<{ status: ContentPostStatus | ''; type: ContentPostType | '' }>({
|
||||
status: '',
|
||||
type: '',
|
||||
});
|
||||
const form = reactive({
|
||||
type: 'PUBLIC_WELFARE_NEWS' as ContentPostType,
|
||||
title: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
});
|
||||
const errorText = (e: unknown) =>
|
||||
e instanceof ApiClientError
|
||||
? `${e.message}${e.requestId ? `(编号:${e.requestId})` : ''}`
|
||||
: '操作失败,请稍后重试';
|
||||
async function load(target = 1) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await fetchContent({
|
||||
page: target,
|
||||
pageSize,
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.type ? { type: filters.type } : {}),
|
||||
});
|
||||
items.value = r.items;
|
||||
page.value = r.page;
|
||||
total.value = r.total;
|
||||
} catch (e) {
|
||||
message.value = errorText(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
busy.value = true;
|
||||
try {
|
||||
await createContent({ clientRequestId: crypto.randomUUID(), ...form });
|
||||
Object.assign(form, { title: '', summary: '', content: '' });
|
||||
showCreate.value = false;
|
||||
message.value = '内容草稿已创建,发布前必须提交审核。';
|
||||
await load(1);
|
||||
} catch (e) {
|
||||
message.value = errorText(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function transition(item: ContentPostListItem) {
|
||||
const targetStatus =
|
||||
item.status === 'DRAFT'
|
||||
? 'PENDING_REVIEW'
|
||||
: item.status === 'PENDING_REVIEW'
|
||||
? 'PUBLISHED'
|
||||
: 'ARCHIVED';
|
||||
const note = window.prompt(
|
||||
targetStatus === 'PENDING_REVIEW'
|
||||
? '请填写送审说明'
|
||||
: targetStatus === 'PUBLISHED'
|
||||
? '请填写审核结论'
|
||||
: '请填写归档原因',
|
||||
);
|
||||
if (!note) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await transitionContent(item.publicId, {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
targetStatus,
|
||||
note,
|
||||
...(targetStatus === 'PUBLISHED' && item.type === 'SERVICE_STORY'
|
||||
? { privacyReviewed: true as const }
|
||||
: {}),
|
||||
});
|
||||
message.value = '状态已更新并写入不可变审核历史。';
|
||||
await load(page.value);
|
||||
} catch (e) {
|
||||
message.value = errorText(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function reject(item: ContentPostListItem) {
|
||||
const note = window.prompt('请填写退回修改原因');
|
||||
if (!note) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await transitionContent(item.publicId, {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
targetStatus: 'DRAFT',
|
||||
note,
|
||||
});
|
||||
await load(page.value);
|
||||
} catch (e) {
|
||||
message.value = errorText(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(() => load());
|
||||
</script>
|
||||
<template>
|
||||
<main class="content-area">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>内容发布与审核</h1>
|
||||
<p>公益动态、机构介绍、服务故事、公告和常见问题统一审核后公开。</p>
|
||||
</div>
|
||||
<button class="primary-button compact-button" @click="showCreate = !showCreate">
|
||||
{{ showCreate ? '取消新建' : '新建内容' }}
|
||||
</button>
|
||||
</header>
|
||||
<form v-if="showCreate" class="panel form-grid" @submit.prevent="save">
|
||||
<label
|
||||
>内容类型<select v-model="form.type">
|
||||
<option v-for="t in CONTENT_POST_TYPES" :key="t" :value="t">
|
||||
{{ CONTENT_POST_TYPE_LABELS[t] }}
|
||||
</option>
|
||||
</select></label
|
||||
><label class="form-span"
|
||||
>标题<input v-model.trim="form.title" required minlength="2" maxlength="200" /></label
|
||||
><label class="form-span"
|
||||
>摘要<textarea
|
||||
v-model.trim="form.summary"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="500"
|
||||
></textarea></label
|
||||
><label class="form-span"
|
||||
>正文(纯文本或 Markdown)<textarea
|
||||
v-model.trim="form.content"
|
||||
required
|
||||
minlength="20"
|
||||
maxlength="50000"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="form-actions form-span">
|
||||
<button class="primary-button compact-button" :disabled="busy">保存草稿</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="filter-panel" @submit.prevent="load(1)">
|
||||
<div class="filter-field">
|
||||
<label>类型</label
|
||||
><select v-model="filters.type">
|
||||
<option value="">全部</option>
|
||||
<option v-for="t in CONTENT_POST_TYPES" :key="t" :value="t">
|
||||
{{ CONTENT_POST_TYPE_LABELS[t] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>状态</label
|
||||
><select v-model="filters.status">
|
||||
<option value="">全部</option>
|
||||
<option v-for="s in CONTENT_POST_STATUSES" :key="s" :value="s">
|
||||
{{ CONTENT_POST_STATUS_LABELS[s] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="primary-button compact-button">查询</button>
|
||||
</form>
|
||||
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
||||
<section v-if="loading" class="panel">正在加载内容…</section>
|
||||
<section v-else-if="!items.length" class="empty-panel">
|
||||
<h2>暂无内容</h2>
|
||||
<p>可先创建公益动态或机构介绍草稿。</p>
|
||||
</section>
|
||||
<section v-else class="table-panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>内容</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.publicId">
|
||||
<td>
|
||||
<strong>{{ item.title }}</strong
|
||||
><br />{{ item.summary }}
|
||||
</td>
|
||||
<td>{{ CONTENT_POST_TYPE_LABELS[item.type] }}</td>
|
||||
<td>{{ CONTENT_POST_STATUS_LABELS[item.status] }}</td>
|
||||
<td>{{ new Date(item.updatedAt).toLocaleString('zh-CN') }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="item.status !== 'ARCHIVED'"
|
||||
class="link-button"
|
||||
:disabled="busy"
|
||||
@click="transition(item)"
|
||||
>
|
||||
{{
|
||||
item.status === 'DRAFT'
|
||||
? '提交审核'
|
||||
: item.status === 'PENDING_REVIEW'
|
||||
? '审核发布'
|
||||
: '归档'
|
||||
}}</button
|
||||
><button
|
||||
v-if="item.status === 'PENDING_REVIEW'"
|
||||
class="link-button"
|
||||
:disabled="busy"
|
||||
@click="reject(item)"
|
||||
>
|
||||
退回
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
|
||||
><span>第 {{ page }} / {{ totalPages }} 页</span
|
||||
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,469 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
FOLLOW_UP_RESULT_CODES,
|
||||
FOLLOW_UP_RESULT_LABELS,
|
||||
FOLLOW_UP_STATUSES,
|
||||
FOLLOW_UP_STATUS_LABELS,
|
||||
FOLLOW_UP_TYPES,
|
||||
FOLLOW_UP_TYPE_LABELS,
|
||||
SERVICE_CATEGORY_LABELS,
|
||||
type AssigneeOption,
|
||||
type BeneficiaryListItem,
|
||||
type FollowUpDetail,
|
||||
type FollowUpListItem,
|
||||
type FollowUpResultCode,
|
||||
type FollowUpStatus,
|
||||
type FollowUpType,
|
||||
type FollowUpWorkbenchSummary,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { ApiClientError } from '../api/api-client';
|
||||
import { fetchAssigneeOptions, fetchBeneficiaries } from '../api/case-management';
|
||||
import {
|
||||
completeFollowUp,
|
||||
createFollowUp,
|
||||
fetchFollowUp,
|
||||
fetchFollowUps,
|
||||
fetchFollowUpSummary,
|
||||
} from '../api/follow-ups';
|
||||
import { formatChinaDateTime } from '../application-format';
|
||||
|
||||
const items = ref<FollowUpListItem[]>([]);
|
||||
const selected = ref<FollowUpDetail | null>(null);
|
||||
const summary = ref<FollowUpWorkbenchSummary>({
|
||||
dueToday: 0,
|
||||
overdue: 0,
|
||||
dueThisWeek: 0,
|
||||
keyCarePending: 0,
|
||||
});
|
||||
const beneficiaries = ref<BeneficiaryListItem[]>([]);
|
||||
const assignees = ref<AssigneeOption[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const showCreate = ref(false);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const total = ref(0);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
const filters = reactive<{
|
||||
status: FollowUpStatus | '';
|
||||
followUpType: FollowUpType | '';
|
||||
overdue: boolean;
|
||||
}>({ status: 'PENDING', followUpType: '', overdue: false });
|
||||
const createForm = reactive({
|
||||
beneficiaryPublicId: '',
|
||||
ownerStaffPublicId: '',
|
||||
followUpType: 'MANUAL' as 'KEY_CARE' | 'MANUAL',
|
||||
dueAt: '',
|
||||
reason: '',
|
||||
});
|
||||
const completeForm = reactive({
|
||||
resultCode: 'SATISFIED' as FollowUpResultCode,
|
||||
resultNote: '',
|
||||
continuationConsentConfirmed: false,
|
||||
continuationDescription: '',
|
||||
});
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof ApiClientError
|
||||
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
|
||||
: '操作失败,请稍后重试';
|
||||
}
|
||||
|
||||
async function load(targetPage = 1): Promise<void> {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const [list, counts] = await Promise.all([
|
||||
fetchFollowUps({
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.followUpType ? { followUpType: filters.followUpType } : {}),
|
||||
...(filters.overdue ? { overdue: true } : {}),
|
||||
}),
|
||||
fetchFollowUpSummary(),
|
||||
]);
|
||||
items.value = list.items;
|
||||
total.value = list.total;
|
||||
page.value = list.page;
|
||||
summary.value = counts;
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions(): Promise<void> {
|
||||
try {
|
||||
const [beneficiaryResult, assigneeResult] = await Promise.all([
|
||||
fetchBeneficiaries({ page: 1, pageSize: 100, status: 'ACTIVE' }),
|
||||
fetchAssigneeOptions(),
|
||||
]);
|
||||
beneficiaries.value = beneficiaryResult.items;
|
||||
assignees.value = assigneeResult.filter((item) => item.type === 'STAFF');
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function open(publicId: string): Promise<void> {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
selected.value = (await fetchFollowUp(publicId)).followUp;
|
||||
Object.assign(completeForm, {
|
||||
resultCode: 'SATISFIED',
|
||||
resultNote: '',
|
||||
continuationConsentConfirmed: false,
|
||||
continuationDescription: '',
|
||||
});
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCreate(): Promise<void> {
|
||||
if (
|
||||
!createForm.beneficiaryPublicId ||
|
||||
!createForm.ownerStaffPublicId ||
|
||||
!createForm.dueAt ||
|
||||
createForm.reason.trim().length < 2
|
||||
) {
|
||||
message.value = '请完整选择服务对象、负责人并填写回访安排。';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
await createFollowUp({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
beneficiaryPublicId: createForm.beneficiaryPublicId,
|
||||
ownerStaffPublicId: createForm.ownerStaffPublicId,
|
||||
followUpType: createForm.followUpType,
|
||||
dueAt: new Date(createForm.dueAt).toISOString(),
|
||||
reason: createForm.reason.trim(),
|
||||
});
|
||||
showCreate.value = false;
|
||||
Object.assign(createForm, {
|
||||
beneficiaryPublicId: '',
|
||||
ownerStaffPublicId: '',
|
||||
followUpType: 'MANUAL',
|
||||
dueAt: '',
|
||||
reason: '',
|
||||
});
|
||||
await load(1);
|
||||
message.value = '回访任务已创建并记录审计。';
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCompletion(): Promise<void> {
|
||||
if (!selected.value || completeForm.resultNote.trim().length < 2) {
|
||||
message.value = '请填写回访结果说明。';
|
||||
return;
|
||||
}
|
||||
if (
|
||||
completeForm.resultCode === 'NEED_MORE_HELP' &&
|
||||
(!completeForm.continuationConsentConfirmed ||
|
||||
completeForm.continuationDescription.trim().length < 10)
|
||||
) {
|
||||
message.value = '继续帮扶需要确认授权,并填写不少于 10 个字的新需求。';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await completeFollowUp(selected.value.publicId, {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
resultCode: completeForm.resultCode,
|
||||
resultNote: completeForm.resultNote.trim(),
|
||||
...(completeForm.resultCode === 'NEED_MORE_HELP'
|
||||
? {
|
||||
continuationConsentConfirmed: true as const,
|
||||
continuationDescription: completeForm.continuationDescription.trim(),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
selected.value = result.followUp;
|
||||
await load(page.value);
|
||||
message.value = result.followUp.continuedApplicationNo
|
||||
? `回访已完成,继续帮扶申请 ${result.followUp.continuedApplicationNo} 已创建。`
|
||||
: '回访已完成,原服务申请已闭环。';
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showOverdue(): void {
|
||||
filters.status = 'PENDING';
|
||||
filters.overdue = true;
|
||||
void load(1);
|
||||
}
|
||||
|
||||
function isOverdue(item: FollowUpListItem): boolean {
|
||||
return item.status === 'PENDING' && new Date(item.dueAt).getTime() < Date.now();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([load(), loadOptions()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="content-area" tabindex="-1">
|
||||
<template v-if="selected">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<button class="back-button" @click="selected = null">← 返回回访工作台</button>
|
||||
<h1>{{ selected.followUpNo }}</h1>
|
||||
<p>
|
||||
{{ selected.beneficiaryDisplayName }} · {{ selected.followUpTypeLabel }} ·
|
||||
{{ selected.ownerDisplayName }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'status-tag',
|
||||
selected.status === 'COMPLETED' ? 'status-tag--success' : 'status-tag--warning',
|
||||
]"
|
||||
>{{ selected.statusLabel }}</span
|
||||
>
|
||||
</header>
|
||||
<div class="audit-notice">
|
||||
回访原因与结论属于敏感服务信息,仅限履职使用;查看与提交均会留痕。
|
||||
</div>
|
||||
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
||||
<section class="detail-grid">
|
||||
<article class="panel">
|
||||
<h2>回访安排</h2>
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>服务对象</dt>
|
||||
<dd>{{ selected.beneficiaryDisplayName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>服务类型</dt>
|
||||
<dd>{{ SERVICE_CATEGORY_LABELS[selected.category] }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>计划时间</dt>
|
||||
<dd>{{ formatChinaDateTime(selected.dueAt) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>关联工单</dt>
|
||||
<dd>{{ selected.serviceCaseNo ?? '独立回访' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<h3>回访原因</h3>
|
||||
<p class="description-text">{{ selected.reason }}</p>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>回访结果</h2>
|
||||
<template v-if="selected.status === 'COMPLETED'">
|
||||
<p>
|
||||
<strong>{{ selected.resultLabel }}</strong>
|
||||
</p>
|
||||
<p class="description-text">{{ selected.resultNote }}</p>
|
||||
<p v-if="selected.continuedApplicationNo" class="consent-note">
|
||||
已创建继续帮扶申请:{{ selected.continuedApplicationNo }}
|
||||
</p>
|
||||
</template>
|
||||
<form v-else class="form-grid" @submit.prevent="saveCompletion">
|
||||
<label class="form-span"
|
||||
>回访结论<select v-model="completeForm.resultCode">
|
||||
<option v-for="code in FOLLOW_UP_RESULT_CODES" :key="code" :value="code">
|
||||
{{ FOLLOW_UP_RESULT_LABELS[code] }}
|
||||
</option>
|
||||
</select></label
|
||||
><label class="form-span"
|
||||
>结果说明<textarea
|
||||
v-model="completeForm.resultNote"
|
||||
maxlength="5000"
|
||||
required
|
||||
></textarea></label
|
||||
><template v-if="completeForm.resultCode === 'NEED_MORE_HELP'">
|
||||
<label class="form-span"
|
||||
>新的帮扶需求<textarea
|
||||
v-model="completeForm.continuationDescription"
|
||||
minlength="10"
|
||||
maxlength="5000"
|
||||
required
|
||||
></textarea></label
|
||||
><label class="consent-check form-span"
|
||||
><input v-model="completeForm.continuationConsentConfirmed" type="checkbox" />
|
||||
已取得服务对象或监护人对创建继续帮扶申请的明确授权</label
|
||||
>
|
||||
</template>
|
||||
<div class="form-actions form-span">
|
||||
<button class="primary-button compact-button" :disabled="busy">
|
||||
{{ busy ? '正在提交…' : '完成回访' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>回访工作台</h1>
|
||||
<p>优先处理逾期、今日到期和重点关爱对象的回访任务。</p>
|
||||
</div>
|
||||
<button class="primary-button compact-button" @click="showCreate = !showCreate">
|
||||
{{ showCreate ? '收起新建表单' : '新建回访' }}
|
||||
</button>
|
||||
</header>
|
||||
<section class="summary-grid" aria-label="回访任务概览">
|
||||
<button class="summary-card summary-card--danger" type="button" @click="showOverdue">
|
||||
<span>已逾期</span><strong>{{ summary.overdue }}</strong
|
||||
><small>优先处理</small>
|
||||
</button>
|
||||
<article class="summary-card">
|
||||
<span>今日到期</span><strong>{{ summary.dueToday }}</strong
|
||||
><small>按时联系</small>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<span>未来 7 日</span><strong>{{ summary.dueThisWeek }}</strong
|
||||
><small>提前安排</small>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<span>重点关爱待办</span><strong>{{ summary.keyCarePending }}</strong
|
||||
><small>持续关注</small>
|
||||
</article>
|
||||
</section>
|
||||
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
||||
<section v-if="showCreate" class="panel workflow-panel">
|
||||
<h2>新建人工回访</h2>
|
||||
<form class="form-grid" @submit.prevent="saveCreate">
|
||||
<label
|
||||
>服务对象<select v-model="createForm.beneficiaryPublicId" required>
|
||||
<option value="">请选择</option>
|
||||
<option v-for="item in beneficiaries" :key="item.publicId" :value="item.publicId">
|
||||
{{ item.displayName }}{{ item.keyCareFlag ? ' · 重点关爱' : '' }}
|
||||
</option>
|
||||
</select></label
|
||||
><label
|
||||
>负责人<select v-model="createForm.ownerStaffPublicId" required>
|
||||
<option value="">请选择</option>
|
||||
<option v-for="item in assignees" :key="item.userPublicId" :value="item.userPublicId">
|
||||
{{ item.displayName }}
|
||||
</option>
|
||||
</select></label
|
||||
><label
|
||||
>回访类型<select v-model="createForm.followUpType">
|
||||
<option value="MANUAL">人工回访</option>
|
||||
<option value="KEY_CARE">重点关爱回访</option>
|
||||
</select></label
|
||||
><label>计划时间<input v-model="createForm.dueAt" type="datetime-local" required /></label
|
||||
><label class="form-span"
|
||||
>回访原因<textarea v-model="createForm.reason" maxlength="2000" required></textarea>
|
||||
</label>
|
||||
<div class="form-actions form-span">
|
||||
<button class="primary-button compact-button" :disabled="busy">
|
||||
{{ busy ? '正在创建…' : '创建回访任务' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<form class="filter-panel" @submit.prevent="load(1)">
|
||||
<div class="filter-field">
|
||||
<label>状态</label
|
||||
><select v-model="filters.status">
|
||||
<option value="">全部状态</option>
|
||||
<option v-for="status in FOLLOW_UP_STATUSES" :key="status" :value="status">
|
||||
{{ FOLLOW_UP_STATUS_LABELS[status] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>类型</label
|
||||
><select v-model="filters.followUpType">
|
||||
<option value="">全部类型</option>
|
||||
<option v-for="type in FOLLOW_UP_TYPES" :key="type" :value="type">
|
||||
{{ FOLLOW_UP_TYPE_LABELS[type] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label class="consent-check"
|
||||
><input v-model="filters.overdue" type="checkbox" /> 仅看逾期待办</label
|
||||
>
|
||||
</div>
|
||||
<button class="primary-button compact-button">查询</button>
|
||||
</form>
|
||||
<section v-if="loading" class="panel">
|
||||
<div v-for="n in 6" :key="n" class="table-skeleton"></div>
|
||||
</section>
|
||||
<section v-else-if="!items.length" class="empty-panel">
|
||||
<h2>暂无符合条件的回访</h2>
|
||||
<p>服务完成或工作人员新建回访后,任务会显示在这里。</p>
|
||||
</section>
|
||||
<section v-else class="table-panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>回访编号</th>
|
||||
<th>服务对象</th>
|
||||
<th>类型</th>
|
||||
<th>计划时间</th>
|
||||
<th>负责人</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.publicId">
|
||||
<td>{{ item.followUpNo }}</td>
|
||||
<td>
|
||||
{{ item.beneficiaryDisplayName }}
|
||||
<span v-if="item.beneficiaryKeyCareFlag" class="status-tag status-tag--warning"
|
||||
>重点关爱</span
|
||||
>
|
||||
</td>
|
||||
<td>{{ item.followUpTypeLabel }}</td>
|
||||
<td :class="{ 'danger-text': isOverdue(item) }">
|
||||
{{ formatChinaDateTime(item.dueAt)
|
||||
}}<small v-if="isOverdue(item)" class="block-note">已逾期</small>
|
||||
</td>
|
||||
<td>{{ item.ownerDisplayName }}</td>
|
||||
<td>
|
||||
<span
|
||||
:class="[
|
||||
'status-tag',
|
||||
item.status === 'COMPLETED'
|
||||
? 'status-tag--success'
|
||||
: isOverdue(item)
|
||||
? 'status-tag--danger'
|
||||
: 'status-tag--info',
|
||||
]"
|
||||
>{{ item.statusLabel }}</span
|
||||
>
|
||||
</td>
|
||||
<td><button class="link-button" @click="open(item.publicId)">查看处理</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
|
||||
><span>第 {{ page }} / {{ totalPages }} 页</span
|
||||
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import type { NotificationDeliveryItem } from '@lehuo/contracts';
|
||||
import { fetchDeliveries, retryDelivery } from '../api/content-notifications';
|
||||
const items = ref<NotificationDeliveryItem[]>([]),
|
||||
loading = ref(true),
|
||||
message = ref('');
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
items.value = (await fetchDeliveries({ page: 1, pageSize: 50 })).items;
|
||||
} catch (e) {
|
||||
message.value = e instanceof Error ? e.message : '加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function retry(id: string) {
|
||||
await retryDelivery(id);
|
||||
message.value = '已重新加入安全投递队列。';
|
||||
await load();
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
<template>
|
||||
<main class="content-area">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>通知投递</h1>
|
||||
<p>站内消息独立持久化;微信失败不会回滚业务,可审计重试。</p>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="message" class="global-message">{{ message }}</p>
|
||||
<section v-if="loading" class="panel">加载中…</section>
|
||||
<section v-else-if="!items.length" class="empty-panel">
|
||||
<h2>暂无微信投递</h2>
|
||||
<p>用户授权订阅且模板配置完成后会生成投递任务。</p>
|
||||
</section>
|
||||
<section v-else class="table-panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>尝试</th>
|
||||
<th>下次尝试</th>
|
||||
<th>错误码</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in items" :key="i.publicId">
|
||||
<td>{{ i.type }}</td>
|
||||
<td>{{ i.status }}</td>
|
||||
<td>{{ i.attemptCount }}</td>
|
||||
<td>{{ new Date(i.nextAttemptAt).toLocaleString('zh-CN') }}</td>
|
||||
<td>{{ i.errorCode ?? '—' }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="i.status === 'FAILED' || i.status === 'DEAD'"
|
||||
class="link-button"
|
||||
@click="retry(i.publicId)"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
PROJECT_STATUSES,
|
||||
PROJECT_STATUS_LABELS,
|
||||
SERVICE_CATEGORIES,
|
||||
SERVICE_CATEGORY_LABELS,
|
||||
type AssigneeOption,
|
||||
type ProjectListItem,
|
||||
type ProjectStatus,
|
||||
type ServiceCategory,
|
||||
} from '@lehuo/contracts';
|
||||
import { ApiClientError } from '../api/api-client';
|
||||
import {
|
||||
createProject,
|
||||
fetchProjectManagerOptions,
|
||||
fetchProjects,
|
||||
transitionProject,
|
||||
} from '../api/participation';
|
||||
|
||||
const items = ref<ProjectListItem[]>([]),
|
||||
managers = ref<AssigneeOption[]>([]),
|
||||
loading = ref(true),
|
||||
busy = ref(false),
|
||||
showCreate = ref(false),
|
||||
message = ref('');
|
||||
const page = ref(1),
|
||||
total = ref(0),
|
||||
pageSize = 20,
|
||||
totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
const filters = reactive<{ status: ProjectStatus | '' }>({ status: '' });
|
||||
const form = reactive({
|
||||
name: '',
|
||||
category: 'OLD_AGE' as ServiceCategory,
|
||||
summary: '',
|
||||
content: '',
|
||||
managerUserPublicId: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
targetPeople: undefined as number | undefined,
|
||||
isPublic: true,
|
||||
sortOrder: 0,
|
||||
});
|
||||
const errorText = (error: unknown) =>
|
||||
error instanceof ApiClientError
|
||||
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
|
||||
: '操作失败,请稍后重试';
|
||||
async function load(target = 1) {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await fetchProjects({
|
||||
page: target,
|
||||
pageSize,
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
});
|
||||
items.value = result.items;
|
||||
total.value = result.total;
|
||||
page.value = result.page;
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (!form.managerUserPublicId || !form.startDate || !form.endDate) {
|
||||
message.value = '请选择负责人并填写项目周期。';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
await createProject({ clientRequestId: crypto.randomUUID(), ...form });
|
||||
showCreate.value = false;
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
targetPeople: undefined,
|
||||
});
|
||||
message.value = '项目草稿已创建。';
|
||||
await load(1);
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function transition(item: ProjectListItem) {
|
||||
const targetStatus = item.status === 'DRAFT' ? 'PUBLISHED' : 'COMPLETED';
|
||||
if (!window.confirm(`确认${targetStatus === 'PUBLISHED' ? '发布' : '完成'}项目“${item.name}”?`))
|
||||
return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await transitionProject(item.publicId, { clientRequestId: crypto.randomUUID(), targetStatus });
|
||||
message.value = '项目状态已更新并记录审计。';
|
||||
await load(page.value);
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
load(),
|
||||
fetchProjectManagerOptions().then((value) => {
|
||||
managers.value = value;
|
||||
}),
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<main class="content-area" tabindex="-1">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>公益项目</h1>
|
||||
<p>维护项目内容、负责人、周期与公开状态。</p>
|
||||
</div>
|
||||
<button class="primary-button compact-button" @click="showCreate = !showCreate">
|
||||
{{ showCreate ? '取消新建' : '新建项目' }}
|
||||
</button>
|
||||
</header>
|
||||
<form v-if="showCreate" class="panel form-grid" @submit.prevent="save">
|
||||
<label
|
||||
>项目名称<input v-model.trim="form.name" required minlength="2" maxlength="200" /></label
|
||||
><label
|
||||
>服务类型<select v-model="form.category">
|
||||
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
|
||||
{{ SERVICE_CATEGORY_LABELS[category] }}
|
||||
</option>
|
||||
</select></label
|
||||
><label
|
||||
>负责人<select v-model="form.managerUserPublicId" required>
|
||||
<option value="">请选择</option>
|
||||
<option
|
||||
v-for="manager in managers"
|
||||
:key="manager.userPublicId"
|
||||
:value="manager.userPublicId"
|
||||
>
|
||||
{{ manager.displayName }}
|
||||
</option>
|
||||
</select></label
|
||||
><label>目标人数<input v-model.number="form.targetPeople" type="number" min="1" /></label
|
||||
><label>开始日期<input v-model="form.startDate" type="date" required /></label
|
||||
><label>结束日期<input v-model="form.endDate" type="date" required /></label
|
||||
><label class="form-span"
|
||||
>项目摘要<textarea
|
||||
v-model.trim="form.summary"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="500"
|
||||
></textarea></label
|
||||
><label class="form-span"
|
||||
>项目内容<textarea
|
||||
v-model.trim="form.content"
|
||||
required
|
||||
minlength="20"
|
||||
maxlength="20000"
|
||||
></textarea></label
|
||||
><label><input v-model="form.isPublic" type="checkbox" /> 发布后公开展示</label
|
||||
><label
|
||||
>排序<input v-model.number="form.sortOrder" type="number" min="-10000" max="10000"
|
||||
/></label>
|
||||
<div class="form-actions form-span">
|
||||
<button class="primary-button compact-button" :disabled="busy">
|
||||
{{ busy ? '保存中…' : '保存草稿' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="filter-panel" @submit.prevent="load(1)">
|
||||
<div class="filter-field">
|
||||
<label for="project-status">项目状态</label
|
||||
><select id="project-status" v-model="filters.status">
|
||||
<option value="">全部状态</option>
|
||||
<option v-for="status in PROJECT_STATUSES" :key="status" :value="status">
|
||||
{{ PROJECT_STATUS_LABELS[status] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="primary-button compact-button">查询</button>
|
||||
</form>
|
||||
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
||||
<section v-if="loading" class="panel">正在加载项目…</section>
|
||||
<section v-else-if="!items.length" class="empty-panel">
|
||||
<h2>暂无项目</h2>
|
||||
<p>可先建立项目草稿。</p>
|
||||
</section>
|
||||
<section v-else class="table-panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目</th>
|
||||
<th>负责人</th>
|
||||
<th>周期</th>
|
||||
<th>状态</th>
|
||||
<th>活动/工单</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.publicId">
|
||||
<td>
|
||||
<strong>{{ item.name }}</strong
|
||||
><br />{{ item.projectNo }} · {{ SERVICE_CATEGORY_LABELS[item.category] }}
|
||||
</td>
|
||||
<td>{{ item.managerDisplayName }}</td>
|
||||
<td>{{ item.startDate }} 至 {{ item.endDate }}</td>
|
||||
<td>{{ item.statusLabel }}<br />{{ item.isPublic ? '公开' : '内部' }}</td>
|
||||
<td>{{ item.activityCount }} / {{ item.serviceCaseCount }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="item.status !== 'COMPLETED'"
|
||||
class="link-button"
|
||||
:disabled="busy"
|
||||
@click="transition(item)"
|
||||
>
|
||||
{{ item.status === 'DRAFT' ? '发布' : '完成' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
|
||||
><span>第 {{ page }} / {{ totalPages }} 页</span
|
||||
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,572 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
SERVICE_APPLICATION_STATUSES,
|
||||
SERVICE_APPLICATION_STATUS_LABELS,
|
||||
SERVICE_CASE_PRIORITIES,
|
||||
SERVICE_CASE_PRIORITY_LABELS,
|
||||
SERVICE_CATEGORIES,
|
||||
SERVICE_CATEGORY_LABELS,
|
||||
type ServiceApplicationDetail,
|
||||
type ServiceApplicationListItem,
|
||||
type ServiceApplicationStatus,
|
||||
type ServiceCategory,
|
||||
type AssigneeOption,
|
||||
type BeneficiaryTagOption,
|
||||
type ServiceCasePriority,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { ApiClientError } from '../api/api-client';
|
||||
import {
|
||||
acceptServiceApplication,
|
||||
fetchAssigneeOptions,
|
||||
fetchBeneficiaryTags,
|
||||
transitionServiceApplication,
|
||||
} from '../api/case-management';
|
||||
import {
|
||||
fetchAdminServiceApplication,
|
||||
fetchAdminServiceApplications,
|
||||
} from '../api/service-applications';
|
||||
import { formatChinaDateTime, statusTone } from '../application-format';
|
||||
|
||||
type ViewState = 'loading' | 'success' | 'empty' | 'error' | 'no-permission';
|
||||
|
||||
const state = ref<ViewState>('loading');
|
||||
const message = ref('');
|
||||
const items = ref<ServiceApplicationListItem[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const selected = ref<ServiceApplicationDetail | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const workflowBusy = ref(false);
|
||||
const assignees = ref<AssigneeOption[]>([]);
|
||||
const tags = ref<BeneficiaryTagOption[]>([]);
|
||||
const acceptance = reactive({
|
||||
mode: 'CREATE' as 'CREATE' | 'LINK_EXISTING',
|
||||
existingPublicId: '',
|
||||
ownerStaffPublicId: '',
|
||||
collaboratorUserPublicIds: [] as string[],
|
||||
priority: 'NORMAL' as ServiceCasePriority,
|
||||
summary: '',
|
||||
serviceLocation: '',
|
||||
displayName: '',
|
||||
mobile: '',
|
||||
regionCode: '',
|
||||
birthYear: undefined as number | undefined,
|
||||
keyCareFlag: false,
|
||||
isMinor: false,
|
||||
guardianName: '',
|
||||
guardianRelation: '监护人',
|
||||
guardianMobile: '',
|
||||
tagCodes: [] as string[],
|
||||
});
|
||||
const filters = reactive<{
|
||||
applicationNo: string;
|
||||
serviceCategory: ServiceCategory | '';
|
||||
status: ServiceApplicationStatus | '';
|
||||
}>({ applicationNo: '', serviceCategory: '', status: '' });
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiClientError) {
|
||||
return `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`;
|
||||
}
|
||||
return '服务申请加载失败,请稍后重试';
|
||||
}
|
||||
|
||||
async function loadList(targetPage = page.value): Promise<void> {
|
||||
state.value = 'loading';
|
||||
message.value = '';
|
||||
selected.value = null;
|
||||
try {
|
||||
const result = await fetchAdminServiceApplications({
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
...(filters.applicationNo.trim()
|
||||
? { applicationNo: filters.applicationNo.trim().toUpperCase() }
|
||||
: {}),
|
||||
...(filters.serviceCategory ? { serviceCategory: filters.serviceCategory } : {}),
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
});
|
||||
page.value = result.page;
|
||||
items.value = result.items;
|
||||
total.value = result.total;
|
||||
state.value = result.items.length ? 'success' : 'empty';
|
||||
} catch (error) {
|
||||
state.value =
|
||||
error instanceof ApiClientError && error.code === 'FORBIDDEN' ? 'no-permission' : 'error';
|
||||
message.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(publicId: string): Promise<void> {
|
||||
detailLoading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
selected.value = (await fetchAdminServiceApplication(publicId)).application;
|
||||
acceptance.displayName = selected.value.beneficiaryName;
|
||||
acceptance.mobile = selected.value.contactMobile;
|
||||
acceptance.regionCode = selected.value.regionCode;
|
||||
acceptance.summary = selected.value.description;
|
||||
if (selected.value.status === 'ASSESSING') {
|
||||
[assignees.value, tags.value] = await Promise.all([
|
||||
fetchAssigneeOptions(),
|
||||
fetchBeneficiaryTags(),
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
message.value = errorMessage(error);
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function transition(targetStatus: 'PENDING_CONTACT' | 'ASSESSING'): Promise<void> {
|
||||
if (!selected.value) return;
|
||||
workflowBusy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
await transitionServiceApplication(selected.value.publicId, {
|
||||
targetStatus,
|
||||
reasonCode: targetStatus === 'PENDING_CONTACT' ? 'CONTACT_STARTED' : 'CONTACT_COMPLETED',
|
||||
});
|
||||
await openDetail(selected.value.publicId);
|
||||
message.value = '申请状态已更新并记录审计。';
|
||||
} catch (error) {
|
||||
message.value = errorMessage(error);
|
||||
} finally {
|
||||
workflowBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function accept(): Promise<void> {
|
||||
if (!selected.value || !acceptance.ownerStaffPublicId || acceptance.summary.trim().length < 10) {
|
||||
message.value = '请选择负责人,并填写至少 10 个字的服务计划。';
|
||||
return;
|
||||
}
|
||||
if (acceptance.mode === 'LINK_EXISTING' && !acceptance.existingPublicId) {
|
||||
message.value = '请输入已核验的服务对象档案标识。';
|
||||
return;
|
||||
}
|
||||
if (
|
||||
acceptance.mode === 'CREATE' &&
|
||||
acceptance.isMinor &&
|
||||
(!acceptance.guardianName || !acceptance.guardianMobile)
|
||||
) {
|
||||
message.value = '未成年人档案必须填写监护人及手机号。';
|
||||
return;
|
||||
}
|
||||
workflowBusy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const beneficiary =
|
||||
acceptance.mode === 'LINK_EXISTING'
|
||||
? ({
|
||||
mode: 'LINK_EXISTING',
|
||||
beneficiaryPublicId: acceptance.existingPublicId.trim(),
|
||||
manualMatchConfirmed: true,
|
||||
} as const)
|
||||
: ({
|
||||
mode: 'CREATE',
|
||||
profile: {
|
||||
displayName: acceptance.displayName.trim(),
|
||||
...(acceptance.birthYear ? { birthYear: acceptance.birthYear } : {}),
|
||||
...(acceptance.mobile ? { mobile: acceptance.mobile.trim() } : {}),
|
||||
regionCode: acceptance.regionCode.trim(),
|
||||
keyCareFlag: acceptance.keyCareFlag,
|
||||
isMinor: acceptance.isMinor,
|
||||
contacts: acceptance.isMinor
|
||||
? [
|
||||
{
|
||||
name: acceptance.guardianName.trim(),
|
||||
relation: acceptance.guardianRelation.trim(),
|
||||
mobile: acceptance.guardianMobile.trim(),
|
||||
isGuardian: true,
|
||||
isEmergencyContact: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
tagCodes: acceptance.tagCodes,
|
||||
...(acceptance.isMinor ? { guardianConsentConfirmed: true as const } : {}),
|
||||
},
|
||||
} as const);
|
||||
const result = await acceptServiceApplication(selected.value.publicId, {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
beneficiary,
|
||||
ownerStaffPublicId: acceptance.ownerStaffPublicId,
|
||||
collaboratorUserPublicIds: acceptance.collaboratorUserPublicIds,
|
||||
priority: acceptance.priority,
|
||||
...(acceptance.serviceLocation.trim()
|
||||
? { serviceLocation: acceptance.serviceLocation.trim() }
|
||||
: {}),
|
||||
summary: acceptance.summary.trim(),
|
||||
});
|
||||
message.value = `接单成功,已生成工单 ${result.serviceCase.caseNo}。`;
|
||||
selected.value = null;
|
||||
await loadList(1);
|
||||
} catch (error) {
|
||||
message.value = errorMessage(error);
|
||||
} finally {
|
||||
workflowBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.applicationNo = '';
|
||||
filters.serviceCategory = '';
|
||||
filters.status = '';
|
||||
void loadList(1);
|
||||
}
|
||||
|
||||
onMounted(() => loadList(1));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main id="main-content" class="content-area" tabindex="-1">
|
||||
<template v-if="selected">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<button class="back-button" type="button" @click="selected = null">← 返回申请列表</button>
|
||||
<h1>服务申请详情</h1>
|
||||
<p>查看本次申请的必要信息与状态历史。</p>
|
||||
</div>
|
||||
<span :class="['status-tag', `status-tag--${statusTone(selected.status)}`]">
|
||||
{{ selected.statusLabel }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="audit-notice" role="note">
|
||||
此页面包含服务联系所需的敏感信息,本次查看已记录安全审计。请勿复制或用于服务以外的目的。
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="selected.status === 'SUBMITTED' || selected.status === 'PENDING_CONTACT'"
|
||||
class="panel workflow-panel"
|
||||
>
|
||||
<h2>受理进度</h2>
|
||||
<p>状态必须按顺序推进;系统会在同一事务中写入状态历史和审计记录。</p>
|
||||
<button
|
||||
v-if="selected.status === 'SUBMITTED'"
|
||||
class="primary-button compact-button"
|
||||
:disabled="workflowBusy"
|
||||
@click="transition('PENDING_CONTACT')"
|
||||
>
|
||||
开始联系
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="primary-button compact-button"
|
||||
:disabled="workflowBusy"
|
||||
@click="transition('ASSESSING')"
|
||||
>
|
||||
完成联系,进入评估
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="selected.status === 'ASSESSING'" class="panel workflow-panel">
|
||||
<h2>评估通过并生成工单</h2>
|
||||
<p>接单会原子化建立或关联服务对象档案、指派负责人并生成工单。</p>
|
||||
<form class="form-grid" @submit.prevent="accept">
|
||||
<label
|
||||
>档案处理方式<select v-model="acceptance.mode">
|
||||
<option value="CREATE">建立新档案</option>
|
||||
<option value="LINK_EXISTING">关联已有档案</option>
|
||||
</select></label
|
||||
>
|
||||
<label v-if="acceptance.mode === 'LINK_EXISTING'"
|
||||
>已人工核验的档案标识<input v-model.trim="acceptance.existingPublicId" required
|
||||
/></label>
|
||||
<template v-else>
|
||||
<label
|
||||
>服务对象姓名<input v-model.trim="acceptance.displayName" required maxlength="50"
|
||||
/></label>
|
||||
<label
|
||||
>手机号<input
|
||||
v-model.trim="acceptance.mobile"
|
||||
inputmode="numeric"
|
||||
pattern="1[3-9][0-9]{9}"
|
||||
/></label>
|
||||
<label
|
||||
>所在地区<input v-model.trim="acceptance.regionCode" required maxlength="100"
|
||||
/></label>
|
||||
<label
|
||||
>出生年份<input
|
||||
v-model.number="acceptance.birthYear"
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2200"
|
||||
/></label>
|
||||
<label><input v-model="acceptance.keyCareFlag" type="checkbox" /> 重点关怀对象</label>
|
||||
<label><input v-model="acceptance.isMinor" type="checkbox" /> 未成年人</label>
|
||||
<template v-if="acceptance.isMinor">
|
||||
<label
|
||||
>监护人姓名<input v-model.trim="acceptance.guardianName" required maxlength="50"
|
||||
/></label>
|
||||
<label
|
||||
>与服务对象关系<input
|
||||
v-model.trim="acceptance.guardianRelation"
|
||||
required
|
||||
maxlength="50"
|
||||
/></label>
|
||||
<label
|
||||
>监护人手机号<input
|
||||
v-model.trim="acceptance.guardianMobile"
|
||||
required
|
||||
pattern="1[3-9][0-9]{9}"
|
||||
/></label>
|
||||
<p class="form-span consent-note">
|
||||
提交表示工作人员已取得监护人明确授权;系统将创建独立授权记录。
|
||||
</p>
|
||||
</template>
|
||||
<fieldset class="form-span checkbox-group">
|
||||
<legend>服务标签</legend>
|
||||
<label v-for="tag in tags" :key="tag.code"
|
||||
><input v-model="acceptance.tagCodes" type="checkbox" :value="tag.code" />
|
||||
{{ tag.name }}</label
|
||||
>
|
||||
</fieldset>
|
||||
</template>
|
||||
<label
|
||||
>负责人<select v-model="acceptance.ownerStaffPublicId" required>
|
||||
<option value="">请选择</option>
|
||||
<option
|
||||
v-for="item in assignees.filter((option) => option.type === 'STAFF')"
|
||||
:key="item.userPublicId"
|
||||
:value="item.userPublicId"
|
||||
>
|
||||
{{ item.displayName }}{{ item.department ? ` · ${item.department}` : '' }}
|
||||
</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>优先级<select v-model="acceptance.priority">
|
||||
<option v-for="priority in SERVICE_CASE_PRIORITIES" :key="priority" :value="priority">
|
||||
{{ SERVICE_CASE_PRIORITY_LABELS[priority] }}
|
||||
</option>
|
||||
</select></label
|
||||
>
|
||||
<label class="form-span"
|
||||
>协作人员<select v-model="acceptance.collaboratorUserPublicIds" multiple size="5">
|
||||
<option v-for="item in assignees" :key="item.userPublicId" :value="item.userPublicId">
|
||||
{{ item.displayName }} · {{ item.type === 'STAFF' ? '工作人员' : '志愿者' }}
|
||||
</option>
|
||||
</select></label
|
||||
>
|
||||
<label class="form-span"
|
||||
>服务地点<input v-model.trim="acceptance.serviceLocation" maxlength="300"
|
||||
/></label>
|
||||
<label class="form-span"
|
||||
>服务计划<textarea
|
||||
v-model.trim="acceptance.summary"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="2000"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="form-actions form-span">
|
||||
<button class="primary-button compact-button" :disabled="workflowBusy">
|
||||
{{ workflowBusy ? '正在生成…' : '确认接单并生成工单' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
||||
|
||||
<section class="detail-grid">
|
||||
<article class="panel">
|
||||
<h2>申请概况</h2>
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>服务编号</dt>
|
||||
<dd class="tabular">{{ selected.applicationNo }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>服务类型</dt>
|
||||
<dd>{{ selected.serviceCategoryLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>申请关系</dt>
|
||||
<dd>{{ selected.applyModeLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>服务对象</dt>
|
||||
<dd>{{ selected.beneficiaryName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>所在地区</dt>
|
||||
<dd>{{ selected.regionCode }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>联系电话</dt>
|
||||
<dd class="sensitive-value">{{ selected.contactMobile }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>期望联系</dt>
|
||||
<dd>{{ selected.preferredContactTime ?? '未填写' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>提交时间</dt>
|
||||
<dd>{{ formatChinaDateTime(selected.submittedAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h2>补充情况</h2>
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>希望上门</dt>
|
||||
<dd>{{ selected.needsHomeVisit ? '是' : '否或未说明' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>行动不便</dt>
|
||||
<dd>{{ selected.mobilityDifficulty ? '是' : '否或未说明' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<h3>需求说明</h3>
|
||||
<p class="description-text">{{ selected.description }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel timeline-panel">
|
||||
<h2>状态历史</h2>
|
||||
<ol class="admin-timeline">
|
||||
<li v-for="history in selected.history" :key="history.createdAt">
|
||||
<span class="timeline-marker" aria-hidden="true"></span>
|
||||
<div>
|
||||
<strong>{{ history.statusLabel }}</strong>
|
||||
<p>{{ history.reasonLabel }}</p>
|
||||
<time :datetime="history.createdAt">{{
|
||||
formatChinaDateTime(history.createdAt)
|
||||
}}</time>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>服务申请</h1>
|
||||
<p>查看服务需求,并按状态机完成联系、评估、接单和工单生成。</p>
|
||||
</div>
|
||||
<span class="result-count">共 {{ total }} 条</span>
|
||||
</header>
|
||||
|
||||
<form class="filter-panel" aria-label="申请筛选" @submit.prevent="loadList(1)">
|
||||
<div class="filter-field">
|
||||
<label for="application-no">服务编号</label>
|
||||
<input id="application-no" v-model="filters.applicationNo" placeholder="输入服务编号" />
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label for="service-category">服务类型</label>
|
||||
<select id="service-category" v-model="filters.serviceCategory">
|
||||
<option value="">全部类型</option>
|
||||
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
|
||||
{{ SERVICE_CATEGORY_LABELS[category] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label for="application-status">当前状态</label>
|
||||
<select id="application-status" v-model="filters.status">
|
||||
<option value="">全部状态</option>
|
||||
<option v-for="status in SERVICE_APPLICATION_STATUSES" :key="status" :value="status">
|
||||
{{ SERVICE_APPLICATION_STATUS_LABELS[status] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button class="primary-button compact-button" type="submit">查询</button>
|
||||
<button class="secondary-button compact-button" type="button" @click="resetFilters">
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section v-if="state === 'loading'" class="panel" aria-live="polite">
|
||||
<div v-for="index in 6" :key="index" class="table-skeleton"></div>
|
||||
<p class="sr-only">正在加载服务申请</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="state === 'empty'" class="empty-panel">
|
||||
<h2>没有符合条件的服务申请</h2>
|
||||
<p>可以调整筛选条件后重新查询。</p>
|
||||
<button class="secondary-button compact-button" type="button" @click="resetFilters">
|
||||
清除筛选
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-else-if="state === 'no-permission'" class="empty-panel" role="alert">
|
||||
<h2>您暂无权限查看服务申请</h2>
|
||||
<p>{{ message }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="state === 'error'" class="empty-panel" role="alert">
|
||||
<h2>服务申请加载失败</h2>
|
||||
<p>{{ message }}</p>
|
||||
<button class="secondary-button compact-button" type="button" @click="loadList()">
|
||||
重新加载
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-else class="table-panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<caption class="sr-only">
|
||||
服务申请列表
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>服务编号</th>
|
||||
<th>服务对象</th>
|
||||
<th>服务类型</th>
|
||||
<th>联系电话</th>
|
||||
<th>状态</th>
|
||||
<th>提交时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.publicId">
|
||||
<td class="tabular">{{ item.applicationNo }}</td>
|
||||
<td>{{ item.beneficiaryName }}</td>
|
||||
<td>{{ item.serviceCategoryLabel }}</td>
|
||||
<td class="tabular">{{ item.contactMobileMasked }}</td>
|
||||
<td>
|
||||
<span :class="['status-tag', `status-tag--${statusTone(item.status)}`]">{{
|
||||
item.statusLabel
|
||||
}}</span>
|
||||
</td>
|
||||
<td>{{ formatChinaDateTime(item.submittedAt) }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="link-button"
|
||||
type="button"
|
||||
:disabled="detailLoading"
|
||||
@click="openDetail(item.publicId)"
|
||||
>
|
||||
{{ detailLoading ? '读取中…' : '查看' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" aria-label="分页">
|
||||
<button type="button" :disabled="page <= 1" @click="loadList(page - 1)">上一页</button>
|
||||
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
||||
<button type="button" :disabled="page >= totalPages" @click="loadList(page + 1)">
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="message && state === 'success'" class="global-message" role="alert">{{ message }}</p>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,590 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
BENEFICIARY_CONFIRM_STATUSES,
|
||||
SERVICE_CASE_STATUSES,
|
||||
SERVICE_CASE_STATUS_LABELS,
|
||||
SERVICE_CATEGORIES,
|
||||
SERVICE_CATEGORY_LABELS,
|
||||
SERVICE_RECORD_TYPES,
|
||||
SERVICE_RECORD_TYPE_LABELS,
|
||||
type AssigneeOption,
|
||||
type ServiceCaseDetail,
|
||||
type ServiceCaseListItem,
|
||||
type ServiceCaseStatus,
|
||||
type ServiceCategory,
|
||||
type ServiceRecordItem,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { ApiClientError } from '../api/api-client';
|
||||
import {
|
||||
confirmUpload,
|
||||
completeServiceCase,
|
||||
createServiceRecord,
|
||||
createUploadIntent,
|
||||
fetchAssigneeOptions,
|
||||
fetchDownloadUrl,
|
||||
fetchServiceCase,
|
||||
fetchServiceCases,
|
||||
replaceCaseAssignees,
|
||||
startServiceCase,
|
||||
updateServiceRecord,
|
||||
} from '../api/case-management';
|
||||
import { formatChinaDateTime } from '../application-format';
|
||||
|
||||
const items = ref<ServiceCaseListItem[]>([]);
|
||||
const selected = ref<ServiceCaseDetail | null>(null);
|
||||
const options = ref<AssigneeOption[]>([]);
|
||||
const collaborators = ref<string[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const filters = reactive<{
|
||||
caseNo: string;
|
||||
status: ServiceCaseStatus | '';
|
||||
category: ServiceCategory | '';
|
||||
}>({ caseNo: '', status: '', category: '' });
|
||||
const recordForm = reactive({
|
||||
publicId: '',
|
||||
revision: 0,
|
||||
serviceStartAt: '',
|
||||
serviceEndAt: '',
|
||||
serviceType: 'HOME_VISIT' as (typeof SERVICE_RECORD_TYPES)[number],
|
||||
content: '',
|
||||
result: '',
|
||||
followUpSuggestion: '',
|
||||
beneficiaryConfirmStatus: 'NOT_REQUESTED' as (typeof BENEFICIARY_CONFIRM_STATUSES)[number],
|
||||
attachmentPublicIds: [] as string[],
|
||||
});
|
||||
const pendingFiles = ref<Array<{ publicId: string; filename: string }>>([]);
|
||||
const completionForm = reactive({ resultSummary: '', followUpPlan: '', followUpDueAt: '' });
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof ApiClientError
|
||||
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
|
||||
: '操作失败,请稍后重试';
|
||||
}
|
||||
function localDate(value: string): string {
|
||||
return value ? new Date(value).toISOString() : '';
|
||||
}
|
||||
function resetRecord(): void {
|
||||
Object.assign(recordForm, {
|
||||
publicId: '',
|
||||
revision: 0,
|
||||
serviceStartAt: '',
|
||||
serviceEndAt: '',
|
||||
serviceType: 'HOME_VISIT',
|
||||
content: '',
|
||||
result: '',
|
||||
followUpSuggestion: '',
|
||||
beneficiaryConfirmStatus: 'NOT_REQUESTED',
|
||||
attachmentPublicIds: [],
|
||||
});
|
||||
pendingFiles.value = [];
|
||||
}
|
||||
|
||||
async function load(targetPage = 1): Promise<void> {
|
||||
loading.value = true;
|
||||
selected.value = null;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await fetchServiceCases({
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
...(filters.caseNo.trim() ? { caseNo: filters.caseNo.trim().toUpperCase() } : {}),
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.category ? { category: filters.category } : {}),
|
||||
});
|
||||
items.value = result.items;
|
||||
total.value = result.total;
|
||||
page.value = result.page;
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function open(publicId: string): Promise<void> {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const [caseResult, assignees] = await Promise.all([
|
||||
fetchServiceCase(publicId),
|
||||
fetchAssigneeOptions().catch(() => []),
|
||||
]);
|
||||
selected.value = caseResult.serviceCase;
|
||||
options.value = assignees;
|
||||
collaborators.value = caseResult.serviceCase.assignees
|
||||
.filter((item) => item.role === 'COLLABORATOR')
|
||||
.map((item) => item.userPublicId);
|
||||
resetRecord();
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
if (!selected.value) return;
|
||||
busy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
selected.value = (await startServiceCase(selected.value.publicId)).serviceCase;
|
||||
message.value = '服务已开始,申请进度与工单状态已同步更新。';
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeCase(): Promise<void> {
|
||||
if (
|
||||
!selected.value ||
|
||||
completionForm.resultSummary.trim().length < 2 ||
|
||||
completionForm.followUpPlan.trim().length < 2 ||
|
||||
!completionForm.followUpDueAt
|
||||
) {
|
||||
message.value = '请完整填写服务总结、回访计划和回访时间。';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const publicId = selected.value.publicId;
|
||||
const result = await completeServiceCase(publicId, {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
resultSummary: completionForm.resultSummary.trim(),
|
||||
followUpPlan: completionForm.followUpPlan.trim(),
|
||||
followUpDueAt: localDate(completionForm.followUpDueAt),
|
||||
});
|
||||
await open(publicId);
|
||||
message.value = `服务已完成,回访任务 ${result.followUp.followUpNo} 已自动生成。`;
|
||||
Object.assign(completionForm, { resultSummary: '', followUpPlan: '', followUpDueAt: '' });
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAssignees(): Promise<void> {
|
||||
if (!selected.value) return;
|
||||
busy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
selected.value = (
|
||||
await replaceCaseAssignees(selected.value.publicId, {
|
||||
collaboratorUserPublicIds: collaborators.value,
|
||||
})
|
||||
).serviceCase;
|
||||
message.value = '协作人员已更新并记录审计。';
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!selected.value || !file) return;
|
||||
busy.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const intent = await createUploadIntent(selected.value.publicId, {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
filename: file.name,
|
||||
mimeType: file.type as 'image/jpeg' | 'image/png' | 'image/webp',
|
||||
size: file.size,
|
||||
});
|
||||
const uploaded = await fetch(intent.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: intent.uploadHeaders,
|
||||
body: file,
|
||||
});
|
||||
if (!uploaded.ok) throw new Error('对象存储上传失败');
|
||||
const confirmed = await confirmUpload(intent.file.publicId);
|
||||
recordForm.attachmentPublicIds.push(confirmed.file.publicId);
|
||||
pendingFiles.value.push({
|
||||
publicId: confirmed.file.publicId,
|
||||
filename: confirmed.file.filename,
|
||||
});
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function editRecord(record: ServiceRecordItem): void {
|
||||
Object.assign(recordForm, {
|
||||
publicId: record.publicId,
|
||||
revision: record.revision,
|
||||
serviceStartAt: record.serviceStartAt.slice(0, 16),
|
||||
serviceEndAt: record.serviceEndAt?.slice(0, 16) ?? '',
|
||||
serviceType: record.serviceType,
|
||||
content: record.content,
|
||||
result: record.result,
|
||||
followUpSuggestion: record.followUpSuggestion ?? '',
|
||||
beneficiaryConfirmStatus: record.beneficiaryConfirmStatus,
|
||||
attachmentPublicIds: record.attachments.map((item) => item.publicId),
|
||||
});
|
||||
pendingFiles.value = record.attachments.map(({ publicId, filename }) => ({ publicId, filename }));
|
||||
}
|
||||
|
||||
async function saveRecord(): Promise<void> {
|
||||
if (
|
||||
!selected.value ||
|
||||
!recordForm.serviceStartAt ||
|
||||
recordForm.content.trim().length < 2 ||
|
||||
recordForm.result.trim().length < 2
|
||||
) {
|
||||
message.value = '请完整填写服务时间、内容和结果。';
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
message.value = '';
|
||||
const editing = Boolean(recordForm.publicId);
|
||||
const common = {
|
||||
serviceStartAt: localDate(recordForm.serviceStartAt),
|
||||
...(recordForm.serviceEndAt ? { serviceEndAt: localDate(recordForm.serviceEndAt) } : {}),
|
||||
serviceType: recordForm.serviceType,
|
||||
content: recordForm.content.trim(),
|
||||
result: recordForm.result.trim(),
|
||||
...(recordForm.followUpSuggestion.trim()
|
||||
? { followUpSuggestion: recordForm.followUpSuggestion.trim() }
|
||||
: {}),
|
||||
beneficiaryConfirmStatus: recordForm.beneficiaryConfirmStatus,
|
||||
attachmentPublicIds: recordForm.attachmentPublicIds,
|
||||
};
|
||||
try {
|
||||
if (recordForm.publicId)
|
||||
await updateServiceRecord(selected.value.publicId, recordForm.publicId, {
|
||||
...common,
|
||||
expectedRevision: recordForm.revision,
|
||||
});
|
||||
else
|
||||
await createServiceRecord(selected.value.publicId, {
|
||||
...common,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
});
|
||||
await open(selected.value.publicId);
|
||||
message.value = editing ? '服务记录已更新。' : '服务记录已保存。';
|
||||
resetRecord();
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function download(publicId: string): Promise<void> {
|
||||
try {
|
||||
window.open((await fetchDownloadUrl(publicId)).downloadUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="content-area" tabindex="-1">
|
||||
<template v-if="selected">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<button class="back-button" @click="selected = null">← 返回工单列表</button>
|
||||
<h1>{{ selected.caseNo }}</h1>
|
||||
<p>
|
||||
{{ selected.beneficiaryDisplayName }} ·
|
||||
{{ SERVICE_CATEGORY_LABELS[selected.category] }} · 负责人
|
||||
{{ selected.ownerDisplayName }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="status-tag status-tag--info">{{ selected.statusLabel }}</span>
|
||||
</header>
|
||||
<div class="audit-notice">
|
||||
仅可将以下信息用于已分配的公益服务;查看、指派和记录操作均会留痕。
|
||||
</div>
|
||||
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
||||
<section class="detail-grid">
|
||||
<article class="panel">
|
||||
<h2>服务计划</h2>
|
||||
<dl class="detail-list">
|
||||
<div>
|
||||
<dt>地区</dt>
|
||||
<dd>{{ selected.regionCode }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>联系电话</dt>
|
||||
<dd>{{ selected.beneficiaryMobile ?? '未留存' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>服务地点</dt>
|
||||
<dd>{{ selected.serviceLocation ?? '未填写' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>计划说明</dt>
|
||||
<dd>{{ selected.summary }}</dd>
|
||||
</div>
|
||||
<div v-if="selected.resultSummary">
|
||||
<dt>服务总结</dt>
|
||||
<dd>{{ selected.resultSummary }}</dd>
|
||||
</div>
|
||||
<div v-if="selected.followUpPlan">
|
||||
<dt>回访计划</dt>
|
||||
<dd>{{ selected.followUpPlan }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
v-if="selected.status === 'ASSIGNED'"
|
||||
class="primary-button compact-button"
|
||||
:disabled="busy"
|
||||
@click="start"
|
||||
>
|
||||
开始服务
|
||||
</button>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>负责人和协作人员</h2>
|
||||
<p v-for="item in selected.assignees" :key="item.userPublicId">
|
||||
{{ item.role === 'OWNER' ? '负责人' : '协作' }} · {{ item.displayName }}({{
|
||||
item.type === 'STAFF' ? '工作人员' : '志愿者'
|
||||
}})
|
||||
</p>
|
||||
<label
|
||||
>调整协作人员<select v-model="collaborators" multiple size="6">
|
||||
<option v-for="item in options" :key="item.userPublicId" :value="item.userPublicId">
|
||||
{{ item.displayName }} · {{ item.type === 'STAFF' ? '工作人员' : '志愿者' }}
|
||||
</option>
|
||||
</select></label
|
||||
><button class="secondary-button compact-button" :disabled="busy" @click="saveAssignees">
|
||||
保存指派
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
<section class="panel timeline-panel">
|
||||
<h2>状态历史</h2>
|
||||
<ol class="admin-timeline">
|
||||
<li v-for="item in selected.history" :key="item.createdAt">
|
||||
<span class="timeline-marker"></span>
|
||||
<div>
|
||||
<strong>{{ item.statusLabel }}</strong>
|
||||
<p>{{ item.reasonLabel }}</p>
|
||||
<time>{{ formatChinaDateTime(item.createdAt) }}</time>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>{{ recordForm.publicId ? '修改服务记录' : '新增服务记录' }}</h2>
|
||||
<form class="form-grid" @submit.prevent="saveRecord">
|
||||
<label
|
||||
>开始时间<input
|
||||
v-model="recordForm.serviceStartAt"
|
||||
type="datetime-local"
|
||||
required /></label
|
||||
><label>结束时间<input v-model="recordForm.serviceEndAt" type="datetime-local" /></label
|
||||
><label
|
||||
>服务方式<select v-model="recordForm.serviceType">
|
||||
<option v-for="type in SERVICE_RECORD_TYPES" :key="type" :value="type">
|
||||
{{ SERVICE_RECORD_TYPE_LABELS[type] }}
|
||||
</option>
|
||||
</select></label
|
||||
><label
|
||||
>对象确认<select v-model="recordForm.beneficiaryConfirmStatus">
|
||||
<option value="NOT_REQUESTED">未请求确认</option>
|
||||
<option value="CONFIRMED">已确认</option>
|
||||
<option value="PARTIALLY_CONFIRMED">部分确认</option>
|
||||
<option value="DISPUTED">有异议</option>
|
||||
</select></label
|
||||
><label class="form-span"
|
||||
>服务内容<textarea
|
||||
v-model="recordForm.content"
|
||||
maxlength="5000"
|
||||
required
|
||||
></textarea></label
|
||||
><label class="form-span"
|
||||
>服务结果<textarea
|
||||
v-model="recordForm.result"
|
||||
maxlength="5000"
|
||||
required
|
||||
></textarea></label
|
||||
><label class="form-span"
|
||||
>后续建议<textarea
|
||||
v-model="recordForm.followUpSuggestion"
|
||||
maxlength="2000"
|
||||
></textarea></label
|
||||
><label class="form-span"
|
||||
>服务照片(JPEG / PNG / WebP,单张不超过 10 MiB)<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:disabled="busy"
|
||||
@change="upload"
|
||||
/></label>
|
||||
<p v-if="pendingFiles.length" class="form-span">
|
||||
已附加:{{ pendingFiles.map((item) => item.filename).join('、') }}
|
||||
</p>
|
||||
<div class="form-actions form-span">
|
||||
<button
|
||||
class="primary-button compact-button"
|
||||
:disabled="busy || selected.status !== 'IN_SERVICE'"
|
||||
>
|
||||
{{ busy ? '处理中…' : recordForm.publicId ? '保存修改' : '保存记录' }}</button
|
||||
><button
|
||||
v-if="recordForm.publicId"
|
||||
class="secondary-button compact-button"
|
||||
type="button"
|
||||
@click="resetRecord"
|
||||
>
|
||||
取消修改
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel record-list">
|
||||
<h2>服务记录</h2>
|
||||
<p v-if="!selected.records.length">工单开始服务后,可在上方添加记录。</p>
|
||||
<article v-for="record in selected.records" :key="record.publicId" class="record-card">
|
||||
<header>
|
||||
<strong>{{ record.serviceTypeLabel }}</strong
|
||||
><time>{{ formatChinaDateTime(record.serviceStartAt) }}</time>
|
||||
</header>
|
||||
<p>{{ record.content }}</p>
|
||||
<p><strong>结果:</strong>{{ record.result }}</p>
|
||||
<div>
|
||||
<button class="link-button" @click="editRecord(record)">修改</button
|
||||
><button
|
||||
v-for="file in record.attachments"
|
||||
:key="file.publicId"
|
||||
class="link-button"
|
||||
@click="download(file.publicId)"
|
||||
>
|
||||
查看 {{ file.filename }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section v-if="selected.status === 'IN_SERVICE'" class="panel record-list">
|
||||
<h2>完成本阶段服务</h2>
|
||||
<p class="consent-note">
|
||||
完成后将同步更新申请状态并自动生成回访任务。案件至少需要一条服务记录。
|
||||
</p>
|
||||
<form class="form-grid" @submit.prevent="completeCase">
|
||||
<label class="form-span"
|
||||
>服务总结<textarea
|
||||
v-model="completionForm.resultSummary"
|
||||
maxlength="5000"
|
||||
required
|
||||
></textarea></label
|
||||
><label class="form-span"
|
||||
>回访计划<textarea
|
||||
v-model="completionForm.followUpPlan"
|
||||
maxlength="2000"
|
||||
required
|
||||
></textarea></label
|
||||
><label
|
||||
>计划回访时间<input
|
||||
v-model="completionForm.followUpDueAt"
|
||||
type="datetime-local"
|
||||
required
|
||||
/></label>
|
||||
<div class="form-actions form-span">
|
||||
<button class="primary-button compact-button" :disabled="busy">
|
||||
{{ busy ? '正在完成并创建回访…' : '完成服务并创建回访' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
<template v-else>
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>服务工单</h1>
|
||||
<p>按工单状态和服务类型查看已授权范围内的任务。</p>
|
||||
</div>
|
||||
<span class="result-count">共 {{ total }} 个</span>
|
||||
</header>
|
||||
<form class="filter-panel" @submit.prevent="load(1)">
|
||||
<div class="filter-field"><label>工单编号</label><input v-model="filters.caseNo" /></div>
|
||||
<div class="filter-field">
|
||||
<label>状态</label
|
||||
><select v-model="filters.status">
|
||||
<option value="">全部状态</option>
|
||||
<option v-for="status in SERVICE_CASE_STATUSES" :key="status" :value="status">
|
||||
{{ SERVICE_CASE_STATUS_LABELS[status] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>服务类型</label
|
||||
><select v-model="filters.category">
|
||||
<option value="">全部类型</option>
|
||||
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
|
||||
{{ SERVICE_CATEGORY_LABELS[category] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="primary-button compact-button">查询</button>
|
||||
</form>
|
||||
<section v-if="loading" class="panel">
|
||||
<div v-for="n in 6" :key="n" class="table-skeleton"></div>
|
||||
</section>
|
||||
<section v-else-if="message" class="empty-panel">
|
||||
<h2>工单加载失败</h2>
|
||||
<p>{{ message }}</p>
|
||||
<button class="secondary-button compact-button" @click="load(page)">重试</button>
|
||||
</section>
|
||||
<section v-else-if="!items.length" class="empty-panel">
|
||||
<h2>暂无符合条件的工单</h2>
|
||||
<p>申请完成评估与接单后将在此生成工单。</p>
|
||||
</section>
|
||||
<section v-else class="table-panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>工单编号</th>
|
||||
<th>服务对象</th>
|
||||
<th>服务类型</th>
|
||||
<th>优先级</th>
|
||||
<th>状态</th>
|
||||
<th>负责人</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.publicId">
|
||||
<td>{{ item.caseNo }}</td>
|
||||
<td>{{ item.beneficiaryDisplayName }}</td>
|
||||
<td>{{ SERVICE_CATEGORY_LABELS[item.category] }}</td>
|
||||
<td>{{ item.priorityLabel }}</td>
|
||||
<td>
|
||||
<span class="status-tag status-tag--info">{{ item.statusLabel }}</span>
|
||||
</td>
|
||||
<td>{{ item.ownerDisplayName }}</td>
|
||||
<td><button class="link-button" @click="open(item.publicId)">进入工单</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
|
||||
><span>第 {{ page }} / {{ totalPages }} 页</span
|
||||
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { SERVICE_CATEGORY_LABELS, type StatisticsOverview } from '@lehuo/contracts';
|
||||
import { fetchStatistics } from '../api/statistics';
|
||||
const data = ref<StatisticsOverview | null>(null),
|
||||
loading = ref(true),
|
||||
message = ref('');
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await fetchStatistics();
|
||||
} catch (e) {
|
||||
message.value = e instanceof Error ? e.message : '统计加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function download() {
|
||||
const base = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
if (base)
|
||||
window.open(
|
||||
new URL('/api/v1/admin/statistics/export.csv', base).toString(),
|
||||
'_blank',
|
||||
'noopener',
|
||||
);
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
<template>
|
||||
<main class="content-area">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>服务统计</h1>
|
||||
<p>指标直接从业务事实表计算,不支持人工覆盖。</p>
|
||||
</div>
|
||||
<button class="primary-button compact-button" @click="download">导出脱敏汇总 CSV</button>
|
||||
</header>
|
||||
<p v-if="message" class="global-message">{{ message }}</p>
|
||||
<section v-if="loading" class="panel">正在计算统计…</section>
|
||||
<template v-else-if="data"
|
||||
><section class="dashboard-grid">
|
||||
<article class="dashboard-card">
|
||||
<span>申请数量</span><strong>{{ data.applicationCount }}</strong>
|
||||
</article>
|
||||
<article class="dashboard-card">
|
||||
<span>已受理</span><strong>{{ data.acceptedCount }}</strong>
|
||||
</article>
|
||||
<article class="dashboard-card">
|
||||
<span>完成服务</span><strong>{{ data.completedServiceCount }}</strong>
|
||||
</article>
|
||||
<article class="dashboard-card">
|
||||
<span>服务人次</span><strong>{{ data.servicePersonTimes }}</strong>
|
||||
</article>
|
||||
<article class="dashboard-card">
|
||||
<span>服务对象</span><strong>{{ data.uniqueBeneficiaryCount }}</strong>
|
||||
</article>
|
||||
<article class="dashboard-card">
|
||||
<span>志愿者</span><strong>{{ data.volunteerCount }}</strong>
|
||||
</article>
|
||||
<article class="dashboard-card">
|
||||
<span>活动</span><strong>{{ data.activityCount }}</strong>
|
||||
</article>
|
||||
<article class="dashboard-card">
|
||||
<span>志愿分钟</span><strong>{{ data.volunteerMinutes }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>按服务类别</h2>
|
||||
<div v-for="item in data.byCategory" :key="item.category" class="stat-row">
|
||||
<span>{{ SERVICE_CATEGORY_LABELS[item.category] }}</span
|
||||
><strong>{{ item.count }}</strong>
|
||||
</div>
|
||||
<p>统计区间:{{ data.period.startDate }} 至 {{ data.period.endDate }}</p>
|
||||
</section></template
|
||||
>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
VOLUNTEER_STATUSES,
|
||||
VOLUNTEER_STATUS_LABELS,
|
||||
type AdminVolunteerListItem,
|
||||
type VolunteerStatus,
|
||||
} from '@lehuo/contracts';
|
||||
import { ApiClientError } from '../api/api-client';
|
||||
import { fetchVolunteers, reviewVolunteer } from '../api/participation';
|
||||
import { formatChinaDateTime } from '../application-format';
|
||||
|
||||
const items = ref<AdminVolunteerListItem[]>([]),
|
||||
loading = ref(true),
|
||||
busy = ref(false),
|
||||
message = ref('');
|
||||
const page = ref(1),
|
||||
total = ref(0),
|
||||
pageSize = 20;
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
const filters = reactive<{ status: VolunteerStatus | '' }>({ status: 'APPLIED' });
|
||||
const errorText = (error: unknown) =>
|
||||
error instanceof ApiClientError
|
||||
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
|
||||
: '操作失败,请稍后重试';
|
||||
|
||||
async function load(target = 1) {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await fetchVolunteers({
|
||||
page: target,
|
||||
pageSize,
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
});
|
||||
items.value = result.items;
|
||||
total.value = result.total;
|
||||
page.value = result.page;
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function review(
|
||||
item: AdminVolunteerListItem,
|
||||
targetStatus: 'APPROVED' | 'REJECTED' | 'SUSPENDED',
|
||||
) {
|
||||
const note =
|
||||
targetStatus === 'APPROVED'
|
||||
? '资料核验通过'
|
||||
: targetStatus === 'REJECTED'
|
||||
? '资料未通过审核'
|
||||
: '暂停志愿者资格';
|
||||
if (!window.confirm(`确认将“${item.realName}”设为${VOLUNTEER_STATUS_LABELS[targetStatus]}?`))
|
||||
return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await reviewVolunteer(item.publicId, {
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
targetStatus,
|
||||
note,
|
||||
});
|
||||
message.value = '志愿者状态已更新并记录审计。';
|
||||
await load(page.value);
|
||||
} catch (error) {
|
||||
message.value = errorText(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(() => load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="content-area" tabindex="-1">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>志愿者审核</h1>
|
||||
<p>核验最少必要资料,管理志愿者资格与累计服务事实。</p>
|
||||
</div>
|
||||
<span class="result-count">共 {{ total }} 人</span>
|
||||
</header>
|
||||
<form class="filter-panel" @submit.prevent="load(1)">
|
||||
<div class="filter-field">
|
||||
<label for="volunteer-status">资格状态</label
|
||||
><select id="volunteer-status" v-model="filters.status">
|
||||
<option value="">全部状态</option>
|
||||
<option v-for="status in VOLUNTEER_STATUSES" :key="status" :value="status">
|
||||
{{ VOLUNTEER_STATUS_LABELS[status] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="primary-button compact-button">查询</button>
|
||||
</form>
|
||||
<p v-if="message" class="global-message" role="status">{{ message }}</p>
|
||||
<section v-if="loading" class="panel" aria-live="polite">正在加载志愿者资料…</section>
|
||||
<section v-else-if="!items.length" class="empty-panel">
|
||||
<h2>暂无符合条件的志愿者</h2>
|
||||
<p>新的申请会显示在这里。</p>
|
||||
</section>
|
||||
<section v-else class="table-panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>志愿者</th>
|
||||
<th>联系方式</th>
|
||||
<th>地区/技能</th>
|
||||
<th>资格</th>
|
||||
<th>服务事实</th>
|
||||
<th>申请时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.publicId">
|
||||
<td>{{ item.realName }}</td>
|
||||
<td>{{ item.mobileMasked }}</td>
|
||||
<td>{{ item.regionCode }}<br />{{ item.skillLabels.join('、') || '未选择' }}</td>
|
||||
<td>
|
||||
<span class="status-tag">{{ item.statusLabel }}</span>
|
||||
</td>
|
||||
<td>{{ item.completedActivityCount }} 次 / {{ item.totalServiceMinutes }} 分钟</td>
|
||||
<td>{{ formatChinaDateTime(item.createdAt) }}</td>
|
||||
<td>
|
||||
<div class="inline-actions">
|
||||
<button
|
||||
v-if="item.status === 'APPLIED'"
|
||||
class="link-button"
|
||||
:disabled="busy"
|
||||
@click="review(item, 'APPROVED')"
|
||||
>
|
||||
通过</button
|
||||
><button
|
||||
v-if="item.status === 'APPLIED'"
|
||||
class="link-button link-button--danger"
|
||||
:disabled="busy"
|
||||
@click="review(item, 'REJECTED')"
|
||||
>
|
||||
拒绝</button
|
||||
><button
|
||||
v-if="item.status === 'APPROVED'"
|
||||
class="link-button link-button--danger"
|
||||
:disabled="busy"
|
||||
@click="review(item, 'SUSPENDED')"
|
||||
>
|
||||
暂停</button
|
||||
><button
|
||||
v-if="item.status === 'SUSPENDED'"
|
||||
class="link-button"
|
||||
:disabled="busy"
|
||||
@click="review(item, 'APPROVED')"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
|
||||
><span>第 {{ page }} / {{ totalPages }} 页</span
|
||||
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
|
||||
const component: DefineComponent;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM node:24.13.0-alpine AS build
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/contracts/package.json packages/contracts/package.json
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY apps/api apps/api
|
||||
COPY packages/contracts packages/contracts
|
||||
RUN DATABASE_URL=mysql://build:build@127.0.0.1:3306/build pnpm --filter @lehuo/api... build
|
||||
|
||||
FROM node:24.13.0-alpine AS runtime
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
RUN corepack enable
|
||||
COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
|
||||
COPY --from=build /app/apps/api/node_modules ./apps/api/node_modules
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=build /app/packages/contracts/package.json ./packages/contracts/package.json
|
||||
COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/api/dist/main.js"]
|
||||
@@ -0,0 +1,7 @@
|
||||
# API
|
||||
|
||||
NestJS 模块化单体 API。已完成 Phase 0 至 Phase 5:健康检查、认证/RBAC/审计、服务申请、服务对象与工单、服务记录与私有附件、回访闭环、公益项目、活动报名、志愿者审核、签到和服务时长。关键状态变更事务一致,敏感档案/记录加密,修改保留 revision 或不可变事实历史。
|
||||
|
||||
开发启动:`pnpm --filter @lehuo/api dev`。
|
||||
|
||||
附件和项目/活动封面使用 S3 兼容私有对象存储 Adapter。必须配置 `.env.example` 中的 `OBJECT_STORAGE_*` 才能签发上传/下载 URL;未配置时 API 明确返回服务不可用,不使用本地磁盘或模拟存储冒充生产能力。内容与通知属于 Phase 6,尚未开放。
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@lehuo/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"bootstrap:admin": "tsx src/scripts/bootstrap-admin.ts",
|
||||
"build": "pnpm --filter @lehuo/contracts build && prisma generate && tsc -p tsconfig.build.json",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:generate": "prisma generate",
|
||||
"dev": "pnpm --filter @lehuo/contracts build && tsx watch src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"test:integration": "pnpm --filter @lehuo/contracts build && tsx test/run-integration.ts",
|
||||
"test:unit": "pnpm --filter @lehuo/contracts build && vitest run --config vitest.config.ts",
|
||||
"typecheck": "prisma generate && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1106.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1106.0",
|
||||
"@lehuo/contracts": "workspace:*",
|
||||
"@nestjs/common": "^11.1.6",
|
||||
"@nestjs/core": "^11.1.6",
|
||||
"@nestjs/platform-express": "^11.1.6",
|
||||
"@nestjs/swagger": "^11.4.6",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/adapter-mariadb": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"argon2": "^0.45.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"mariadb": "^3.5.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.6",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"prisma": "^7.9.1",
|
||||
"supertest": "^7.1.4",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { defineConfig, env } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
url: env('DATABASE_URL'),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
-- Phase 1 authentication and authorization baseline.
|
||||
-- Internal BIGINT identifiers are never exposed through public APIs.
|
||||
|
||||
CREATE TABLE `users` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`status` ENUM('ACTIVE', 'SUSPENDED', 'DISABLED') NOT NULL DEFAULT 'ACTIVE',
|
||||
`user_type` ENUM('PUBLIC', 'STAFF') NOT NULL,
|
||||
`mobile_encrypted` VARCHAR(512) NULL,
|
||||
`mobile_hash` CHAR(64) NULL,
|
||||
`nickname` VARCHAR(100) NULL,
|
||||
`avatar_url` VARCHAR(512) NULL,
|
||||
`last_login_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `users_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `users_mobile_hash_key`(`mobile_hash`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `wechat_identities` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`appid` VARCHAR(64) NOT NULL,
|
||||
`openid_ciphertext` VARCHAR(512) NOT NULL,
|
||||
`openid_hash` CHAR(64) NOT NULL,
|
||||
`unionid_ciphertext` VARCHAR(512) NULL,
|
||||
`unionid_hash` CHAR(64) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
INDEX `wechat_identities_user_id_idx`(`user_id`),
|
||||
UNIQUE INDEX `wechat_identities_appid_openid_hash_key`(`appid`, `openid_hash`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `staff_profiles` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`employee_no` VARCHAR(50) NULL,
|
||||
`display_name` VARCHAR(100) NOT NULL,
|
||||
`department` VARCHAR(100) NULL,
|
||||
`status` ENUM('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE',
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `staff_profiles_user_id_key`(`user_id`),
|
||||
UNIQUE INDEX `staff_profiles_employee_no_key`(`employee_no`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `admin_credentials` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`username` VARCHAR(64) NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`failed_login_count` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
`locked_until` DATETIME(3) NULL,
|
||||
`password_changed_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `admin_credentials_user_id_key`(`user_id`),
|
||||
UNIQUE INDEX `admin_credentials_username_key`(`username`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `auth_sessions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`channel` ENUM('WECHAT', 'ADMIN') NOT NULL,
|
||||
`token_hash` CHAR(64) NOT NULL,
|
||||
`expires_at` DATETIME(3) NOT NULL,
|
||||
`last_used_at` DATETIME(3) NULL,
|
||||
`revoked_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `auth_sessions_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `auth_sessions_token_hash_key`(`token_hash`),
|
||||
INDEX `auth_sessions_user_channel_status_idx`(`user_id`, `channel`, `revoked_at`, `expires_at`),
|
||||
INDEX `auth_sessions_expires_at_idx`(`expires_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `roles` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`code` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`description` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `roles_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `roles_code_key`(`code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `permissions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`code` VARCHAR(100) NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`description` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `permissions_code_key`(`code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `user_roles` (
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`role_id` BIGINT UNSIGNED NOT NULL,
|
||||
`assigned_by` BIGINT UNSIGNED NULL,
|
||||
`assigned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
INDEX `user_roles_role_id_idx`(`role_id`),
|
||||
INDEX `user_roles_assigned_by_idx`(`assigned_by`),
|
||||
PRIMARY KEY (`user_id`, `role_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `role_permissions` (
|
||||
`role_id` BIGINT UNSIGNED NOT NULL,
|
||||
`permission_id` BIGINT UNSIGNED NOT NULL,
|
||||
INDEX `role_permissions_permission_id_idx`(`permission_id`),
|
||||
PRIMARY KEY (`role_id`, `permission_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `audit_logs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`actor_user_id` BIGINT UNSIGNED NULL,
|
||||
`actor_role` VARCHAR(64) NULL,
|
||||
`action` VARCHAR(100) NOT NULL,
|
||||
`resource_type` VARCHAR(100) NULL,
|
||||
`resource_public_id` VARCHAR(100) NULL,
|
||||
`result` ENUM('SUCCESS', 'FAILURE') NOT NULL,
|
||||
`metadata_json` JSON NULL,
|
||||
`ip_hash` CHAR(64) NULL,
|
||||
`user_agent_summary` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
INDEX `audit_logs_actor_created_at_idx`(`actor_user_id`, `created_at`),
|
||||
INDEX `audit_logs_resource_created_at_idx`(`resource_type`, `resource_public_id`, `created_at`),
|
||||
INDEX `audit_logs_action_created_at_idx`(`action`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `wechat_identities` ADD CONSTRAINT `wechat_identities_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `staff_profiles` ADD CONSTRAINT `staff_profiles_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `admin_credentials` ADD CONSTRAINT `admin_credentials_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `auth_sessions` ADD CONSTRAINT `auth_sessions_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_role_id_fkey` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_assigned_by_fkey` FOREIGN KEY (`assigned_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_role_id_fkey` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_permission_id_fkey` FOREIGN KEY (`permission_id`) REFERENCES `permissions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `audit_logs` ADD CONSTRAINT `audit_logs_actor_user_id_fkey` FOREIGN KEY (`actor_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `roles` (`public_id`, `code`, `name`, `description`, `updated_at`) VALUES
|
||||
('9fc73dca-5e75-4ce6-b8ad-d270bd020001', 'VOLUNTEER', '志愿者', '仅访问本人被分配的最小必要任务信息', CURRENT_TIMESTAMP(3)),
|
||||
('9fc73dca-5e75-4ce6-b8ad-d270bd020002', 'STAFF', '工作人员', '按负责范围处理公益服务', CURRENT_TIMESTAMP(3)),
|
||||
('9fc73dca-5e75-4ce6-b8ad-d270bd020003', 'PROJECT_MANAGER', '项目负责人', '按项目范围管理服务', CURRENT_TIMESTAMP(3)),
|
||||
('9fc73dca-5e75-4ce6-b8ad-d270bd020004', 'ADMIN', '管理员', '跨项目业务管理', CURRENT_TIMESTAMP(3)),
|
||||
('9fc73dca-5e75-4ce6-b8ad-d270bd020005', 'SUPER_ADMIN', '超级管理员', '系统级配置维护,日常不使用', CURRENT_TIMESTAMP(3));
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('admin:access', '访问管理后台', '允许进入管理后台'),
|
||||
('users:read', '查看用户', '按授权范围查看用户基础信息'),
|
||||
('users:manage', '管理用户', '管理用户状态与基础信息'),
|
||||
('roles:manage', '管理角色权限', '分配角色和维护权限'),
|
||||
('audit:read', '查看审计日志', '按权限查看审计记录'),
|
||||
('system:manage', '管理系统配置', '维护系统级配置'),
|
||||
('sensitive:read', '查看敏感信息', '经审计查看必要敏感信息'),
|
||||
('sensitive:export', '导出敏感信息', '受控导出敏感信息'),
|
||||
('resources:access_all', '跨范围访问资源', '按角色范围访问非本人资源');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code = 'STAFF' AND p.code IN ('admin:access', 'users:read');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code = 'PROJECT_MANAGER' AND p.code IN ('admin:access', 'users:read', 'resources:access_all');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code = 'ADMIN' AND p.code IN ('admin:access', 'users:read', 'users:manage', 'roles:manage', 'audit:read', 'sensitive:read', 'resources:access_all');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code = 'SUPER_ADMIN';
|
||||
@@ -0,0 +1,81 @@
|
||||
-- Phase 2 service application baseline.
|
||||
-- L3 fields are encrypted by the application before persistence.
|
||||
|
||||
CREATE TABLE `consents` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`consent_type` ENUM('SERVICE_APPLICATION') NOT NULL,
|
||||
`version` VARCHAR(32) NOT NULL,
|
||||
`granted_by` ENUM('SELF', 'APPLICANT') NOT NULL,
|
||||
`granted_at` DATETIME(3) NOT NULL,
|
||||
`revoked_at` DATETIME(3) NULL,
|
||||
`evidence_json` JSON NULL,
|
||||
`ip_hash` CHAR(64) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `consents_public_id_key`(`public_id`),
|
||||
INDEX `consents_user_type_granted_at_idx`(`user_id`, `consent_type`, `granted_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `service_applications` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`application_no` VARCHAR(32) NOT NULL,
|
||||
`applicant_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`consent_id` BIGINT UNSIGNED NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`apply_mode` ENUM('SELF', 'FAMILY', 'COMMUNITY', 'VOLUNTEER', 'OTHER') NOT NULL,
|
||||
`service_category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL,
|
||||
`beneficiary_name_encrypted` VARCHAR(512) NOT NULL,
|
||||
`contact_mobile_encrypted` VARCHAR(512) NOT NULL,
|
||||
`contact_mobile_hash` CHAR(64) NOT NULL,
|
||||
`region_code` VARCHAR(100) NOT NULL,
|
||||
`description_encrypted` TEXT NOT NULL,
|
||||
`preferred_contact_time` VARCHAR(100) NULL,
|
||||
`needs_home_visit` BOOLEAN NULL,
|
||||
`mobility_difficulty` BOOLEAN NULL,
|
||||
`status` ENUM('SUBMITTED', 'PENDING_CONTACT', 'ASSESSING', 'REJECTED', 'OUT_OF_SCOPE', 'REFERRED', 'ACCEPTED', 'SERVICE_PLANNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'FOLLOW_UP_PENDING', 'CLOSED', 'CANCELLED', 'SUSPENDED') NOT NULL DEFAULT 'SUBMITTED',
|
||||
`source` ENUM('WECHAT_MINIPROGRAM', 'ADMIN') NOT NULL DEFAULT 'WECHAT_MINIPROGRAM',
|
||||
`submitted_at` DATETIME(3) NOT NULL,
|
||||
`accepted_at` DATETIME(3) NULL,
|
||||
`closed_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `service_applications_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `service_applications_application_no_key`(`application_no`),
|
||||
UNIQUE INDEX `service_applications_consent_id_key`(`consent_id`),
|
||||
UNIQUE INDEX `service_applications_applicant_client_request_key`(`applicant_user_id`, `client_request_id`),
|
||||
INDEX `service_applications_applicant_submitted_at_idx`(`applicant_user_id`, `submitted_at`),
|
||||
INDEX `service_applications_status_submitted_at_idx`(`status`, `submitted_at`),
|
||||
INDEX `service_applications_category_submitted_at_idx`(`service_category`, `submitted_at`),
|
||||
INDEX `service_applications_contact_mobile_hash_idx`(`contact_mobile_hash`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `service_application_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`application_id` BIGINT UNSIGNED NOT NULL,
|
||||
`from_status` ENUM('SUBMITTED', 'PENDING_CONTACT', 'ASSESSING', 'REJECTED', 'OUT_OF_SCOPE', 'REFERRED', 'ACCEPTED', 'SERVICE_PLANNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'FOLLOW_UP_PENDING', 'CLOSED', 'CANCELLED', 'SUSPENDED') NULL,
|
||||
`to_status` ENUM('SUBMITTED', 'PENDING_CONTACT', 'ASSESSING', 'REJECTED', 'OUT_OF_SCOPE', 'REFERRED', 'ACCEPTED', 'SERVICE_PLANNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'FOLLOW_UP_PENDING', 'CLOSED', 'CANCELLED', 'SUSPENDED') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`reason_code` VARCHAR(100) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
INDEX `application_status_history_application_created_at_idx`(`application_id`, `created_at`),
|
||||
INDEX `application_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `consents` ADD CONSTRAINT `consents_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_applicant_user_id_fkey` FOREIGN KEY (`applicant_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_consent_id_fkey` FOREIGN KEY (`consent_id`) REFERENCES `consents`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `service_application_status_history` ADD CONSTRAINT `service_application_status_history_application_id_fkey` FOREIGN KEY (`application_id`) REFERENCES `service_applications`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `service_application_status_history` ADD CONSTRAINT `service_application_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('applications:read', '查看服务申请', '按授权范围查看服务申请列表和详情');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code IN ('STAFF', 'PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN') AND p.code = 'applications:read';
|
||||
@@ -0,0 +1,359 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `consents` ADD COLUMN `beneficiary_id` BIGINT UNSIGNED NULL,
|
||||
MODIFY `user_id` BIGINT UNSIGNED NULL,
|
||||
MODIFY `consent_type` ENUM('SERVICE_APPLICATION', 'BENEFICIARY_GUARDIAN') NOT NULL,
|
||||
MODIFY `granted_by` ENUM('SELF', 'APPLICANT', 'GUARDIAN') NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `service_applications` ADD COLUMN `beneficiary_id` BIGINT UNSIGNED NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `beneficiary_profiles` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`display_name_encrypted` VARCHAR(512) NOT NULL,
|
||||
`display_name_hash` CHAR(64) NOT NULL,
|
||||
`gender` ENUM('FEMALE', 'MALE', 'OTHER', 'UNDISCLOSED') NULL,
|
||||
`birth_year` SMALLINT UNSIGNED NULL,
|
||||
`mobile_encrypted` VARCHAR(512) NULL,
|
||||
`mobile_hash` CHAR(64) NULL,
|
||||
`region_code` VARCHAR(100) NOT NULL,
|
||||
`address_detail_encrypted` VARCHAR(1024) NULL,
|
||||
`primary_category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL,
|
||||
`key_care_flag` BOOLEAN NOT NULL DEFAULT false,
|
||||
`is_minor` BOOLEAN NOT NULL DEFAULT false,
|
||||
`status` ENUM('ACTIVE', 'INACTIVE', 'ARCHIVED') NOT NULL DEFAULT 'ACTIVE',
|
||||
`notes_encrypted` TEXT NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `beneficiary_profiles_public_id_key`(`public_id`),
|
||||
INDEX `beneficiary_profiles_display_name_hash_idx`(`display_name_hash`),
|
||||
INDEX `beneficiary_profiles_mobile_hash_idx`(`mobile_hash`),
|
||||
INDEX `beneficiary_profiles_category_status_created_at_idx`(`primary_category`, `status`, `created_at`),
|
||||
INDEX `beneficiary_profiles_key_care_status_created_at_idx`(`key_care_flag`, `status`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `beneficiary_contacts` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`beneficiary_id` BIGINT UNSIGNED NOT NULL,
|
||||
`name_encrypted` VARCHAR(512) NOT NULL,
|
||||
`relation` VARCHAR(50) NOT NULL,
|
||||
`mobile_encrypted` VARCHAR(512) NOT NULL,
|
||||
`mobile_hash` CHAR(64) NOT NULL,
|
||||
`is_guardian` BOOLEAN NOT NULL DEFAULT false,
|
||||
`is_emergency_contact` BOOLEAN NOT NULL DEFAULT false,
|
||||
`consent_record_id` BIGINT UNSIGNED NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
`removed_at` DATETIME(3) NULL,
|
||||
|
||||
UNIQUE INDEX `beneficiary_contacts_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `beneficiary_contacts_consent_record_id_key`(`consent_record_id`),
|
||||
INDEX `beneficiary_contacts_beneficiary_active_guardian_idx`(`beneficiary_id`, `removed_at`, `is_guardian`),
|
||||
INDEX `beneficiary_contacts_mobile_hash_idx`(`mobile_hash`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `tags` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`code` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(50) NOT NULL,
|
||||
`description` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `tags_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `tags_code_key`(`code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `beneficiary_tags` (
|
||||
`beneficiary_id` BIGINT UNSIGNED NOT NULL,
|
||||
`tag_id` BIGINT UNSIGNED NOT NULL,
|
||||
`assigned_by` BIGINT UNSIGNED NOT NULL,
|
||||
`assigned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `beneficiary_tags_tag_id_idx`(`tag_id`),
|
||||
PRIMARY KEY (`beneficiary_id`, `tag_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `service_cases` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`case_no` VARCHAR(32) NOT NULL,
|
||||
`application_id` BIGINT UNSIGNED NOT NULL,
|
||||
`accept_request_id` CHAR(36) NOT NULL,
|
||||
`accept_request_fingerprint` CHAR(64) NOT NULL,
|
||||
`beneficiary_id` BIGINT UNSIGNED NOT NULL,
|
||||
`owner_staff_id` BIGINT UNSIGNED NOT NULL,
|
||||
`category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL,
|
||||
`priority` ENUM('LOW', 'NORMAL', 'HIGH', 'URGENT') NOT NULL DEFAULT 'NORMAL',
|
||||
`status` ENUM('ASSIGNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'SUSPENDED', 'CANCELLED') NOT NULL DEFAULT 'ASSIGNED',
|
||||
`planned_start_at` DATETIME(3) NULL,
|
||||
`planned_end_at` DATETIME(3) NULL,
|
||||
`service_location_encrypted` VARCHAR(1024) NULL,
|
||||
`summary_encrypted` TEXT NOT NULL,
|
||||
`result_summary_encrypted` TEXT NULL,
|
||||
`follow_up_plan_encrypted` TEXT NULL,
|
||||
`completed_at` DATETIME(3) NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `service_cases_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `service_cases_case_no_key`(`case_no`),
|
||||
UNIQUE INDEX `service_cases_application_id_key`(`application_id`),
|
||||
INDEX `service_cases_owner_status_planned_idx`(`owner_staff_id`, `status`, `planned_start_at`),
|
||||
INDEX `service_cases_beneficiary_created_at_idx`(`beneficiary_id`, `created_at`),
|
||||
INDEX `service_cases_status_created_at_idx`(`status`, `created_at`),
|
||||
UNIQUE INDEX `service_cases_creator_accept_request_key`(`created_by`, `accept_request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `case_assignees` (
|
||||
`case_id` BIGINT UNSIGNED NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`assignee_type` ENUM('STAFF', 'VOLUNTEER') NOT NULL,
|
||||
`role` ENUM('OWNER', 'COLLABORATOR') NOT NULL,
|
||||
`assigned_by` BIGINT UNSIGNED NOT NULL,
|
||||
`assigned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`removed_at` DATETIME(3) NULL,
|
||||
|
||||
INDEX `case_assignees_user_active_assigned_idx`(`user_id`, `removed_at`, `assigned_at`),
|
||||
PRIMARY KEY (`case_id`, `user_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `service_case_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`case_id` BIGINT UNSIGNED NOT NULL,
|
||||
`from_status` ENUM('ASSIGNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'SUSPENDED', 'CANCELLED') NULL,
|
||||
`to_status` ENUM('ASSIGNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'SUSPENDED', 'CANCELLED') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`reason_code` VARCHAR(100) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `case_status_history_case_created_at_idx`(`case_id`, `created_at`),
|
||||
INDEX `case_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `service_records` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`case_id` BIGINT UNSIGNED NOT NULL,
|
||||
`beneficiary_id` BIGINT UNSIGNED NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`service_start_at` DATETIME(3) NOT NULL,
|
||||
`service_end_at` DATETIME(3) NULL,
|
||||
`service_type` ENUM('HOME_VISIT', 'PHONE_CONTACT', 'ESCORT', 'MATERIAL_SUPPORT', 'ACTIVITY', 'OTHER') NOT NULL,
|
||||
`content_encrypted` TEXT NOT NULL,
|
||||
`result_encrypted` TEXT NOT NULL,
|
||||
`follow_up_suggestion_encrypted` TEXT NULL,
|
||||
`beneficiary_confirm_status` ENUM('NOT_REQUESTED', 'CONFIRMED', 'PARTIALLY_CONFIRMED', 'DISPUTED') NOT NULL DEFAULT 'NOT_REQUESTED',
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `service_records_public_id_key`(`public_id`),
|
||||
INDEX `service_records_case_start_at_idx`(`case_id`, `service_start_at`),
|
||||
INDEX `service_records_beneficiary_start_at_idx`(`beneficiary_id`, `service_start_at`),
|
||||
UNIQUE INDEX `service_records_creator_client_request_key`(`created_by`, `client_request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `service_record_revisions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`service_record_id` BIGINT UNSIGNED NOT NULL,
|
||||
`revision` INTEGER UNSIGNED NOT NULL,
|
||||
`service_start_at` DATETIME(3) NOT NULL,
|
||||
`service_end_at` DATETIME(3) NULL,
|
||||
`service_type` ENUM('HOME_VISIT', 'PHONE_CONTACT', 'ESCORT', 'MATERIAL_SUPPORT', 'ACTIVITY', 'OTHER') NOT NULL,
|
||||
`content_encrypted` TEXT NOT NULL,
|
||||
`result_encrypted` TEXT NOT NULL,
|
||||
`follow_up_suggestion_encrypted` TEXT NULL,
|
||||
`beneficiary_confirm_status` ENUM('NOT_REQUESTED', 'CONFIRMED', 'PARTIALLY_CONFIRMED', 'DISPUTED') NOT NULL,
|
||||
`revised_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `service_record_revisions_reviser_created_at_idx`(`revised_by`, `created_at`),
|
||||
UNIQUE INDEX `service_record_revisions_record_revision_key`(`service_record_id`, `revision`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `files` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`case_id` BIGINT UNSIGNED NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`storage_provider` VARCHAR(50) NOT NULL,
|
||||
`bucket` VARCHAR(255) NOT NULL,
|
||||
`object_key` VARCHAR(512) NOT NULL,
|
||||
`original_filename_encrypted` VARCHAR(1024) NOT NULL,
|
||||
`mime_type` VARCHAR(100) NOT NULL,
|
||||
`size` BIGINT UNSIGNED NOT NULL,
|
||||
`sha256` CHAR(64) NULL,
|
||||
`business_type` ENUM('SERVICE_RECORD') NOT NULL,
|
||||
`uploader_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`status` ENUM('UPLOAD_PENDING', 'AVAILABLE', 'QUARANTINED', 'DELETED') NOT NULL DEFAULT 'UPLOAD_PENDING',
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `files_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `files_object_key_key`(`object_key`),
|
||||
INDEX `files_case_status_created_at_idx`(`case_id`, `status`, `created_at`),
|
||||
INDEX `files_uploader_status_created_at_idx`(`uploader_user_id`, `status`, `created_at`),
|
||||
UNIQUE INDEX `files_uploader_client_request_key`(`uploader_user_id`, `client_request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `service_record_files` (
|
||||
`service_record_id` BIGINT UNSIGNED NOT NULL,
|
||||
`file_id` BIGINT UNSIGNED NOT NULL,
|
||||
`attached_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `service_record_files_file_id_idx`(`file_id`),
|
||||
PRIMARY KEY (`service_record_id`, `file_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `consents_beneficiary_type_granted_at_idx` ON `consents`(`beneficiary_id`, `consent_type`, `granted_at`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `service_applications_beneficiary_submitted_at_idx` ON `service_applications`(`beneficiary_id`, `submitted_at`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `consents` ADD CONSTRAINT `consents_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `beneficiary_profiles` ADD CONSTRAINT `beneficiary_profiles_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `beneficiary_profiles` ADD CONSTRAINT `beneficiary_profiles_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `beneficiary_contacts` ADD CONSTRAINT `beneficiary_contacts_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `beneficiary_contacts` ADD CONSTRAINT `beneficiary_contacts_consent_record_id_fkey` FOREIGN KEY (`consent_record_id`) REFERENCES `consents`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `beneficiary_tags` ADD CONSTRAINT `beneficiary_tags_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `beneficiary_tags` ADD CONSTRAINT `beneficiary_tags_tag_id_fkey` FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `beneficiary_tags` ADD CONSTRAINT `beneficiary_tags_assigned_by_fkey` FOREIGN KEY (`assigned_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_application_id_fkey` FOREIGN KEY (`application_id`) REFERENCES `service_applications`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_owner_staff_id_fkey` FOREIGN KEY (`owner_staff_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `case_assignees` ADD CONSTRAINT `case_assignees_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `case_assignees` ADD CONSTRAINT `case_assignees_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `case_assignees` ADD CONSTRAINT `case_assignees_assigned_by_fkey` FOREIGN KEY (`assigned_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_case_status_history` ADD CONSTRAINT `service_case_status_history_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_case_status_history` ADD CONSTRAINT `service_case_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_records` ADD CONSTRAINT `service_records_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_records` ADD CONSTRAINT `service_records_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_records` ADD CONSTRAINT `service_records_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_records` ADD CONSTRAINT `service_records_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_record_revisions` ADD CONSTRAINT `service_record_revisions_service_record_id_fkey` FOREIGN KEY (`service_record_id`) REFERENCES `service_records`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_record_revisions` ADD CONSTRAINT `service_record_revisions_revised_by_fkey` FOREIGN KEY (`revised_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `files` ADD CONSTRAINT `files_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `files` ADD CONSTRAINT `files_uploader_user_id_fkey` FOREIGN KEY (`uploader_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_record_files` ADD CONSTRAINT `service_record_files_service_record_id_fkey` FOREIGN KEY (`service_record_id`) REFERENCES `service_records`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_record_files` ADD CONSTRAINT `service_record_files_file_id_fkey` FOREIGN KEY (`file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Phase 3 fixed service tags. Product operations may request additions in a later reviewed migration.
|
||||
INSERT INTO `tags` (`public_id`, `code`, `name`, `description`) VALUES
|
||||
('4ed0570a-6051-4a29-9fd2-3c3d45dc52a7', 'ALONE_LIVING', '独居', '独居服务对象'),
|
||||
('044792f8-2982-4890-a8d4-cc6d01ac78c4', 'ADVANCED_AGE', '高龄', '高龄关爱对象'),
|
||||
('dc02d25f-5a4c-4f31-9ec8-c5a5f3904e6a', 'MOBILITY_LIMITED', '行动不便', '日常行动存在困难'),
|
||||
('75292f5f-72e0-4a19-9e9b-5fd7b455956a', 'REGULAR_VISIT', '定期探访', '需要定期探访关爱'),
|
||||
('94e041fe-0745-4f50-a3c6-b0cad8e286b5', 'CHILD_IN_DIFFICULTY', '困境儿童', '需要加强保护的儿童服务对象'),
|
||||
('76e280f4-8f6f-4e06-9570-ce998c8c38c4', 'KEY_CARE', '重点关爱', '由工作人员评估的重点关爱对象');
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('applications:manage', '处理服务申请', '按状态机联系、评估并受理服务申请'),
|
||||
('beneficiaries:read', '查看服务对象', '按授权范围查看服务对象档案'),
|
||||
('beneficiaries:manage', '管理服务对象', '建档并维护服务对象、联系人和标签'),
|
||||
('cases:read', '查看服务工单', '按负责或管理范围查看服务工单'),
|
||||
('cases:manage', '管理服务工单', '按状态机推进负责的服务工单'),
|
||||
('cases:assign', '分配服务工单', '分配负责人和协作人员'),
|
||||
('assigned_cases:read', '查看已分配工单', '查看本人被分配工单的最小必要信息'),
|
||||
('service_records:write', '填写服务记录', '为本人被分配工单新增或修订服务记录'),
|
||||
('files:write', '管理服务附件', '上传、确认和读取授权范围内的私有服务附件');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code IN ('STAFF', 'PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN')
|
||||
AND p.code IN ('applications:manage', 'beneficiaries:read', 'beneficiaries:manage', 'cases:read', 'cases:manage', 'cases:assign', 'assigned_cases:read', 'service_records:write', 'files:write');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code = 'VOLUNTEER'
|
||||
AND p.code IN ('assigned_cases:read', 'service_records:write', 'files:write');
|
||||
@@ -0,0 +1,105 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `service_applications` ADD COLUMN `parent_application_id` BIGINT UNSIGNED NULL,
|
||||
ADD COLUMN `source_follow_up_task_id` BIGINT UNSIGNED NULL,
|
||||
MODIFY `source` ENUM('WECHAT_MINIPROGRAM', 'ADMIN', 'FOLLOW_UP') NOT NULL DEFAULT 'WECHAT_MINIPROGRAM';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `service_cases` ADD COLUMN `complete_request_fingerprint` CHAR(64) NULL,
|
||||
ADD COLUMN `complete_request_id` CHAR(36) NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `follow_up_tasks` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`follow_up_no` VARCHAR(32) NOT NULL,
|
||||
`beneficiary_id` BIGINT UNSIGNED NOT NULL,
|
||||
`case_id` BIGINT UNSIGNED NULL,
|
||||
`owner_staff_id` BIGINT UNSIGNED NOT NULL,
|
||||
`due_at` DATETIME(3) NOT NULL,
|
||||
`follow_up_type` ENUM('SERVICE_COMPLETION', 'KEY_CARE', 'MANUAL') NOT NULL,
|
||||
`status` ENUM('PENDING', 'COMPLETED', 'CANCELLED') NOT NULL DEFAULT 'PENDING',
|
||||
`source` ENUM('AUTO_SERVICE_COMPLETION', 'STAFF_MANUAL', 'KEY_CARE_PLAN') NOT NULL,
|
||||
`reason_encrypted` TEXT NOT NULL,
|
||||
`result_code` ENUM('SATISFIED', 'GENERALLY_SATISFIED', 'UNSATISFIED', 'NEED_MORE_HELP', 'UNREACHABLE', 'OTHER') NULL,
|
||||
`result_note_encrypted` TEXT NULL,
|
||||
`create_request_id` CHAR(36) NOT NULL,
|
||||
`create_request_fingerprint` CHAR(64) NOT NULL,
|
||||
`complete_request_id` CHAR(36) NULL,
|
||||
`complete_request_fingerprint` CHAR(64) NULL,
|
||||
`completed_at` DATETIME(3) NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `follow_up_tasks_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `follow_up_tasks_follow_up_no_key`(`follow_up_no`),
|
||||
INDEX `follow_up_tasks_owner_status_due_at_idx`(`owner_staff_id`, `status`, `due_at`),
|
||||
INDEX `follow_up_tasks_status_due_at_idx`(`status`, `due_at`),
|
||||
INDEX `follow_up_tasks_beneficiary_due_at_idx`(`beneficiary_id`, `due_at`),
|
||||
INDEX `follow_up_tasks_case_created_at_idx`(`case_id`, `created_at`),
|
||||
UNIQUE INDEX `follow_up_tasks_creator_request_key`(`created_by`, `create_request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `follow_up_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`follow_up_task_id` BIGINT UNSIGNED NOT NULL,
|
||||
`from_status` ENUM('PENDING', 'COMPLETED', 'CANCELLED') NULL,
|
||||
`to_status` ENUM('PENDING', 'COMPLETED', 'CANCELLED') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`reason_code` VARCHAR(100) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `follow_up_status_history_task_created_at_idx`(`follow_up_task_id`, `created_at`),
|
||||
INDEX `follow_up_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `service_applications_source_follow_up_task_id_key` ON `service_applications`(`source_follow_up_task_id`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `service_applications_parent_submitted_at_idx` ON `service_applications`(`parent_application_id`, `submitted_at`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX `service_cases_complete_request_id_key` ON `service_cases`(`complete_request_id`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_source_follow_up_task_id_fkey` FOREIGN KEY (`source_follow_up_task_id`) REFERENCES `follow_up_tasks`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_parent_application_id_fkey` FOREIGN KEY (`parent_application_id`) REFERENCES `service_applications`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_owner_staff_id_fkey` FOREIGN KEY (`owner_staff_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `follow_up_status_history` ADD CONSTRAINT `follow_up_status_history_follow_up_task_id_fkey` FOREIGN KEY (`follow_up_task_id`) REFERENCES `follow_up_tasks`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `follow_up_status_history` ADD CONSTRAINT `follow_up_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Phase 4 follow-up permissions. Volunteers do not receive access to beneficiary follow-up results.
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('follow_ups:read', '查看回访任务', '按负责人或管理范围查看回访工作台和任务'),
|
||||
('follow_ups:manage', '创建回访任务', '创建人工或重点关爱回访并在服务完成时生成回访'),
|
||||
('follow_ups:complete', '完成回访任务', '由任务负责人记录回访结论和继续帮扶授权');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code IN ('STAFF', 'PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN')
|
||||
AND p.code IN ('follow_ups:read', 'follow_ups:manage', 'follow_ups:complete');
|
||||
@@ -0,0 +1,267 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `consents` MODIFY `consent_type` ENUM('SERVICE_APPLICATION', 'BENEFICIARY_GUARDIAN', 'VOLUNTEER_APPLICATION') NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `files` MODIFY `case_id` BIGINT UNSIGNED NULL,
|
||||
MODIFY `business_type` ENUM('SERVICE_RECORD', 'PROJECT_COVER', 'ACTIVITY_COVER') NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `service_cases` ADD COLUMN `project_id` BIGINT UNSIGNED NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `volunteers` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`consent_id` BIGINT UNSIGNED NOT NULL,
|
||||
`real_name_encrypted` VARCHAR(512) NOT NULL,
|
||||
`real_name_hash` CHAR(64) NOT NULL,
|
||||
`mobile_encrypted` VARCHAR(512) NOT NULL,
|
||||
`mobile_hash` CHAR(64) NOT NULL,
|
||||
`region_code` VARCHAR(100) NOT NULL,
|
||||
`availability_note_encrypted` TEXT NOT NULL,
|
||||
`status` ENUM('APPLIED', 'APPROVED', 'REJECTED', 'SUSPENDED', 'INACTIVE') NOT NULL DEFAULT 'APPLIED',
|
||||
`approved_by` BIGINT UNSIGNED NULL,
|
||||
`approved_at` DATETIME(3) NULL,
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `volunteers_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `volunteers_user_id_key`(`user_id`),
|
||||
UNIQUE INDEX `volunteers_consent_id_key`(`consent_id`),
|
||||
INDEX `volunteers_status_created_at_idx`(`status`, `created_at`),
|
||||
INDEX `volunteers_mobile_hash_idx`(`mobile_hash`),
|
||||
INDEX `volunteers_region_status_idx`(`region_code`, `status`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `volunteer_skills` (
|
||||
`volunteer_id` BIGINT UNSIGNED NOT NULL,
|
||||
`skill_code` VARCHAR(64) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
INDEX `volunteer_skills_code_idx`(`skill_code`),
|
||||
PRIMARY KEY (`volunteer_id`, `skill_code`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `volunteer_interests` (
|
||||
`volunteer_id` BIGINT UNSIGNED NOT NULL,
|
||||
`category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
INDEX `volunteer_interests_category_idx`(`category`),
|
||||
PRIMARY KEY (`volunteer_id`, `category`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `volunteer_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`volunteer_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('APPLIED', 'APPROVED', 'REJECTED', 'SUSPENDED', 'INACTIVE') NULL,
|
||||
`to_status` ENUM('APPLIED', 'APPROVED', 'REJECTED', 'SUSPENDED', 'INACTIVE') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`reason_code` VARCHAR(100) NOT NULL,
|
||||
`note_encrypted` TEXT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `volunteer_status_history_request_id_key`(`request_id`),
|
||||
INDEX `volunteer_status_history_volunteer_created_at_idx`(`volunteer_id`, `created_at`),
|
||||
INDEX `volunteer_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `projects` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`project_no` VARCHAR(32) NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`name` VARCHAR(200) NOT NULL,
|
||||
`category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL,
|
||||
`summary` VARCHAR(500) NOT NULL,
|
||||
`content` TEXT NOT NULL,
|
||||
`cover_file_id` BIGINT UNSIGNED NULL,
|
||||
`manager_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`start_date` DATE NOT NULL,
|
||||
`end_date` DATE NOT NULL,
|
||||
`target_people` INTEGER UNSIGNED NULL,
|
||||
`status` ENUM('DRAFT', 'PUBLISHED', 'COMPLETED') NOT NULL DEFAULT 'DRAFT',
|
||||
`is_public` BOOLEAN NOT NULL DEFAULT false,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `projects_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `projects_project_no_key`(`project_no`),
|
||||
UNIQUE INDEX `projects_cover_file_id_key`(`cover_file_id`),
|
||||
INDEX `projects_public_status_sort_idx`(`status`, `is_public`, `sort_order`, `created_at`),
|
||||
INDEX `projects_manager_status_idx`(`manager_user_id`, `status`, `created_at`),
|
||||
UNIQUE INDEX `projects_creator_request_key`(`created_by`, `client_request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `project_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`project_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('DRAFT', 'PUBLISHED', 'COMPLETED') NULL,
|
||||
`to_status` ENUM('DRAFT', 'PUBLISHED', 'COMPLETED') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`reason_code` VARCHAR(100) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `project_status_history_request_id_key`(`request_id`),
|
||||
INDEX `project_status_history_project_created_at_idx`(`project_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `activities` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`activity_no` VARCHAR(32) NOT NULL,
|
||||
`project_id` BIGINT UNSIGNED NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`title` VARCHAR(200) NOT NULL,
|
||||
`summary` VARCHAR(500) NOT NULL,
|
||||
`content` TEXT NOT NULL,
|
||||
`cover_file_id` BIGINT UNSIGNED NULL,
|
||||
`start_at` DATETIME(3) NOT NULL,
|
||||
`end_at` DATETIME(3) NOT NULL,
|
||||
`registration_deadline` DATETIME(3) NOT NULL,
|
||||
`location_text` VARCHAR(300) NOT NULL,
|
||||
`capacity` INTEGER UNSIGNED NULL,
|
||||
`status` ENUM('DRAFT', 'PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
|
||||
`published_at` DATETIME(3) NULL,
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `activities_public_id_key`(`public_id`),
|
||||
UNIQUE INDEX `activities_activity_no_key`(`activity_no`),
|
||||
UNIQUE INDEX `activities_cover_file_id_key`(`cover_file_id`),
|
||||
INDEX `activities_status_start_at_idx`(`status`, `start_at`),
|
||||
INDEX `activities_project_start_at_idx`(`project_id`, `start_at`),
|
||||
UNIQUE INDEX `activities_creator_request_key`(`created_by`, `client_request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `activity_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('DRAFT', 'PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NULL,
|
||||
`to_status` ENUM('DRAFT', 'PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`reason_code` VARCHAR(100) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `activity_status_history_request_id_key`(`request_id`),
|
||||
INDEX `activity_status_history_activity_created_at_idx`(`activity_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `activity_registrations` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`volunteer_id` BIGINT UNSIGNED NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') NOT NULL DEFAULT 'PENDING',
|
||||
`check_in_status` ENUM('NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT') NOT NULL DEFAULT 'NOT_CHECKED_IN',
|
||||
`registered_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`reviewed_by` BIGINT UNSIGNED NULL,
|
||||
`reviewed_at` DATETIME(3) NULL,
|
||||
`review_note_encrypted` TEXT NULL,
|
||||
`checked_in_at` DATETIME(3) NULL,
|
||||
`checked_in_by` BIGINT UNSIGNED NULL,
|
||||
`service_minutes` INTEGER UNSIGNED NULL,
|
||||
`service_minutes_recorded_at` DATETIME(3) NULL,
|
||||
`service_minutes_recorded_by` BIGINT UNSIGNED NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `activity_registrations_public_id_key`(`public_id`),
|
||||
INDEX `activity_registrations_activity_status_idx`(`activity_id`, `status`, `registered_at`),
|
||||
INDEX `activity_registrations_volunteer_registered_idx`(`volunteer_id`, `registered_at`),
|
||||
UNIQUE INDEX `activity_registrations_activity_user_key`(`activity_id`, `user_id`),
|
||||
UNIQUE INDEX `activity_registrations_user_request_key`(`user_id`, `client_request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `activity_registration_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`registration_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') NULL,
|
||||
`to_status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') NULL,
|
||||
`from_check_in_status` ENUM('NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT') NULL,
|
||||
`to_check_in_status` ENUM('NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT') NULL,
|
||||
`service_minutes_before` INTEGER UNSIGNED NULL,
|
||||
`service_minutes_after` INTEGER UNSIGNED NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`action_code` VARCHAR(100) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `activity_registration_history_request_id_key`(`request_id`),
|
||||
INDEX `registration_history_registration_created_at_idx`(`registration_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX `service_cases_project_created_at_idx` ON `service_cases`(`project_id`, `created_at`);
|
||||
|
||||
ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_consent_id_fkey` FOREIGN KEY (`consent_id`) REFERENCES `consents`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_approved_by_fkey` FOREIGN KEY (`approved_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `volunteer_skills` ADD CONSTRAINT `volunteer_skills_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `volunteer_interests` ADD CONSTRAINT `volunteer_interests_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `volunteer_status_history` ADD CONSTRAINT `volunteer_status_history_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `volunteer_status_history` ADD CONSTRAINT `volunteer_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `projects` ADD CONSTRAINT `projects_cover_file_id_fkey` FOREIGN KEY (`cover_file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `projects` ADD CONSTRAINT `projects_manager_user_id_fkey` FOREIGN KEY (`manager_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `projects` ADD CONSTRAINT `projects_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `projects` ADD CONSTRAINT `projects_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `project_status_history` ADD CONSTRAINT `project_status_history_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `project_status_history` ADD CONSTRAINT `project_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activities` ADD CONSTRAINT `activities_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activities` ADD CONSTRAINT `activities_cover_file_id_fkey` FOREIGN KEY (`cover_file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activities` ADD CONSTRAINT `activities_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activities` ADD CONSTRAINT `activities_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activity_status_history` ADD CONSTRAINT `activity_status_history_activity_id_fkey` FOREIGN KEY (`activity_id`) REFERENCES `activities`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activity_status_history` ADD CONSTRAINT `activity_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_activity_id_fkey` FOREIGN KEY (`activity_id`) REFERENCES `activities`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_reviewed_by_fkey` FOREIGN KEY (`reviewed_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_checked_in_by_fkey` FOREIGN KEY (`checked_in_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_service_minutes_recorded_by_fkey` FOREIGN KEY (`service_minutes_recorded_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activity_registration_history` ADD CONSTRAINT `activity_registration_history_registration_id_fkey` FOREIGN KEY (`registration_id`) REFERENCES `activity_registrations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `activity_registration_history` ADD CONSTRAINT `activity_registration_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('volunteers:read', '查看志愿者', '按授权范围查看志愿者脱敏列表与详情'),
|
||||
('volunteers:review', '审核志愿者', '审核、暂停和恢复志愿者资格'),
|
||||
('projects:read', '查看公益项目', '按授权范围查看公益项目'),
|
||||
('projects:manage', '管理公益项目', '创建和维护公益项目及关联工单'),
|
||||
('projects:publish', '发布公益项目', '执行公益项目发布和完成状态迁移'),
|
||||
('activities:read', '查看公益活动', '按授权范围查看活动和报名'),
|
||||
('activities:manage', '管理公益活动', '创建和维护公益活动'),
|
||||
('activities:publish', '发布公益活动', '执行活动发布及状态迁移'),
|
||||
('registrations:manage', '管理活动报名', '审核报名、签到并记录服务时长');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code IN ('PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN')
|
||||
AND p.code IN ('volunteers:read', 'volunteers:review', 'projects:read', 'projects:manage', 'projects:publish', 'activities:read', 'activities:manage', 'activities:publish', 'registrations:manage');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code = 'STAFF'
|
||||
AND p.code IN ('volunteers:read', 'projects:read', 'activities:read', 'registrations:manage');
|
||||
@@ -0,0 +1,120 @@
|
||||
-- Phase 6: reviewed public content and durable user notifications.
|
||||
ALTER TABLE `files` MODIFY `business_type` ENUM('SERVICE_RECORD', 'PROJECT_COVER', 'ACTIVITY_COVER', 'CONTENT_COVER') NOT NULL;
|
||||
|
||||
CREATE TABLE `content_posts` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`type` ENUM('ORGANIZATION_INTRO','PUBLIC_WELFARE_NEWS','SERVICE_STORY','NOTICE','FAQ') NOT NULL,
|
||||
`title` VARCHAR(200) NOT NULL,
|
||||
`summary` VARCHAR(500) NOT NULL,
|
||||
`content` TEXT NOT NULL,
|
||||
`cover_file_id` BIGINT UNSIGNED NULL,
|
||||
`status` ENUM('DRAFT','PENDING_REVIEW','PUBLISHED','ARCHIVED') NOT NULL DEFAULT 'DRAFT',
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`privacy_reviewed_at` DATETIME(3) NULL,
|
||||
`reviewed_by` BIGINT UNSIGNED NULL,
|
||||
`reviewed_at` DATETIME(3) NULL,
|
||||
`published_at` DATETIME(3) NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `content_posts_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `content_posts_cover_file_id_key` (`cover_file_id`),
|
||||
UNIQUE INDEX `content_posts_creator_request_key` (`created_by`, `client_request_id`),
|
||||
INDEX `content_posts_status_type_published_idx` (`status`, `type`, `published_at`),
|
||||
INDEX `content_posts_creator_status_updated_idx` (`created_by`, `status`, `updated_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `content_post_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`content_post_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('DRAFT','PENDING_REVIEW','PUBLISHED','ARCHIVED') NULL,
|
||||
`to_status` ENUM('DRAFT','PENDING_REVIEW','PUBLISHED','ARCHIVED') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`reason_code` VARCHAR(100) NOT NULL,
|
||||
`note_encrypted` TEXT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `content_post_status_history_request_id_key` (`request_id`),
|
||||
INDEX `content_status_history_post_created_idx` (`content_post_id`, `created_at`),
|
||||
INDEX `content_status_history_operator_created_idx` (`operator_user_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `notifications` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`event_key` VARCHAR(160) NOT NULL,
|
||||
`type` ENUM('APPLICATION_SUBMITTED','APPLICATION_ACCEPTED','SERVICE_ARRANGED','SERVICE_COMPLETED','FOLLOW_UP_PENDING','ACTIVITY_REGISTRATION_RESULT') NOT NULL,
|
||||
`title` VARCHAR(120) NOT NULL,
|
||||
`body` VARCHAR(500) NOT NULL,
|
||||
`resource_type` VARCHAR(80) NULL,
|
||||
`resource_public_id` CHAR(36) NULL,
|
||||
`read_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `notifications_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `notifications_user_event_key` (`user_id`, `event_key`),
|
||||
INDEX `notifications_user_read_created_idx` (`user_id`, `read_at`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `notification_preferences` (
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`type` ENUM('APPLICATION_SUBMITTED','APPLICATION_ACCEPTED','SERVICE_ARRANGED','SERVICE_COMPLETED','FOLLOW_UP_PENDING','ACTIVITY_REGISTRATION_RESULT') NOT NULL,
|
||||
`in_app_enabled` BOOLEAN NOT NULL DEFAULT true,
|
||||
`wechat_enabled` BOOLEAN NOT NULL DEFAULT false,
|
||||
`authorized_at` DATETIME(3) NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
INDEX `notification_preferences_type_wechat_idx` (`type`, `wechat_enabled`),
|
||||
PRIMARY KEY (`user_id`, `type`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `notification_deliveries` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`notification_id` BIGINT UNSIGNED NOT NULL,
|
||||
`channel` ENUM('WECHAT_SUBSCRIPTION') NOT NULL,
|
||||
`status` ENUM('PENDING','PROCESSING','SENT','FAILED','DEAD') NOT NULL DEFAULT 'PENDING',
|
||||
`attempt_count` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
`next_attempt_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`last_attempt_at` DATETIME(3) NULL,
|
||||
`provider_message_id` VARCHAR(160) NULL,
|
||||
`error_code` VARCHAR(100) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `notification_deliveries_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `notification_deliveries_notification_channel_key` (`notification_id`, `channel`),
|
||||
INDEX `notification_deliveries_status_next_idx` (`status`, `next_attempt_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_cover_file_id_fkey` FOREIGN KEY (`cover_file_id`) REFERENCES `files` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_reviewed_by_fkey` FOREIGN KEY (`reviewed_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `content_post_status_history` ADD CONSTRAINT `content_status_history_post_fkey` FOREIGN KEY (`content_post_id`) REFERENCES `content_posts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `content_post_status_history` ADD CONSTRAINT `content_status_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `notifications` ADD CONSTRAINT `notifications_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `notification_preferences` ADD CONSTRAINT `notification_preferences_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `notification_deliveries` ADD CONSTRAINT `notification_deliveries_notification_id_fkey` FOREIGN KEY (`notification_id`) REFERENCES `notifications` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('content:read', '查看内容', '查看内容草稿、审核状态及历史'),
|
||||
('content:manage', '管理内容', '创建和维护内容草稿'),
|
||||
('content:review', '审核内容', '审核、发布、退回和归档内容'),
|
||||
('notifications:manage', '管理通知', '查看通知投递状态并安全重试');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('ADMIN','SUPER_ADMIN')
|
||||
AND p.code IN ('content:read','content:manage','content:review','notifications:manage');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('STAFF','PROJECT_MANAGER') AND p.code = 'content:read';
|
||||
@@ -0,0 +1,9 @@
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('statistics:read', '查看统计', '查看基于真实业务数据生成的统计指标'),
|
||||
('exports:create', '创建导出', '创建并下载默认脱敏的受控统计导出');
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN ('statistics:read','exports:create');
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code = 'PROJECT_MANAGER' AND p.code = 'statistics:read';
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE `content_post_status_history`
|
||||
DROP FOREIGN KEY `content_status_history_post_fkey`,
|
||||
DROP FOREIGN KEY `content_status_history_operator_fkey`,
|
||||
ADD CONSTRAINT `content_post_status_history_content_post_id_fkey` FOREIGN KEY (`content_post_id`) REFERENCES `content_posts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT `content_post_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1 @@
|
||||
provider = "mysql"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
import { MiddlewareConsumer, Module, type NestModule } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CaseManagementModule } from './case-management/case-management.module';
|
||||
import { AuthenticationGuard } from './auth/authentication.guard';
|
||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||
import { ApiResponseInterceptor } from './common/api-response.interceptor';
|
||||
import { RequestIdMiddleware } from './common/request-id.middleware';
|
||||
import { AppConfigModule } from './config/app-config.module';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { HealthController } from './health/health.controller';
|
||||
import { HealthService } from './health/health.service';
|
||||
import { FollowUpsModule } from './follow-ups/follow-ups.module';
|
||||
import { PermissionGuard } from './permissions/permission.guard';
|
||||
import { PermissionsModule } from './permissions/permissions.module';
|
||||
import { ParticipationModule } from './participation/participation.module';
|
||||
import { SecurityModule } from './security/security.module';
|
||||
import { ServiceApplicationsModule } from './service-applications/service-applications.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { ContentNotificationsModule } from './content-notifications/content-notifications.module';
|
||||
import { StatisticsModule } from './statistics/statistics.module';
|
||||
import { RequestMetricsService } from './common/request-metrics.service';
|
||||
import { RequestMetricsInterceptor } from './common/request-metrics.interceptor';
|
||||
import { SystemMetricsController } from './common/system-metrics.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
SecurityModule,
|
||||
DatabaseModule,
|
||||
AuditModule,
|
||||
PermissionsModule,
|
||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 100 }]),
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
ServiceApplicationsModule,
|
||||
CaseManagementModule,
|
||||
FollowUpsModule,
|
||||
ParticipationModule,
|
||||
ContentNotificationsModule,
|
||||
StatisticsModule,
|
||||
],
|
||||
controllers: [HealthController, SystemMetricsController],
|
||||
providers: [
|
||||
HealthService,
|
||||
RequestMetricsService,
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: AuthenticationGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useClass: RequestMetricsInterceptor },
|
||||
{ provide: APP_FILTER, useClass: ApiExceptionFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestIdMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PrismaService } from '../database/prisma.service';
|
||||
import type { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
describe('AuditService metadata policy', () => {
|
||||
const service = new AuditService({} as PrismaService, {} as SensitiveDataService);
|
||||
|
||||
it('allows non-sensitive action metadata', () => {
|
||||
expect(service.safeMetadata({ reasonCode: 'INVALID_CREDENTIALS', attempt: 2 })).toEqual({
|
||||
reasonCode: 'INVALID_CREDENTIALS',
|
||||
attempt: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects sensitive metadata keys', () => {
|
||||
expect(() => service.safeMetadata({ password: 'never-store-this' })).toThrow(/not allowed/);
|
||||
expect(() => service.safeMetadata({ openid: 'never-store-this' })).toThrow(/not allowed/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { Prisma } from '../generated/prisma/client';
|
||||
import type { AuditResult } from '../generated/prisma/enums';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
|
||||
const SENSITIVE_METADATA_KEY =
|
||||
/(password|token|secret|openid|unionid|mobile|phone|address|description|content)/i;
|
||||
|
||||
export interface AuditEvent {
|
||||
action: string;
|
||||
result: AuditResult;
|
||||
actor?: AuthContext;
|
||||
actorUserId?: bigint;
|
||||
actorRole?: string;
|
||||
resourceType?: string;
|
||||
resourcePublicId?: string;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
|
||||
) {}
|
||||
|
||||
async record(
|
||||
request: RequestContext,
|
||||
event: AuditEvent,
|
||||
client: Prisma.TransactionClient | PrismaService = this.prisma,
|
||||
): Promise<void> {
|
||||
const metadata = this.safeMetadata(event.metadata);
|
||||
const userAgent =
|
||||
request
|
||||
.header('user-agent')
|
||||
?.replace(/[\r\n\t]/g, ' ')
|
||||
.slice(0, 255) || null;
|
||||
await client.auditLog.create({
|
||||
data: {
|
||||
requestId: request.requestId,
|
||||
actorUserId: event.actor?.userId ?? event.actorUserId ?? null,
|
||||
actorRole: event.actor?.roles[0] ?? event.actorRole ?? null,
|
||||
action: event.action,
|
||||
resourceType: event.resourceType ?? null,
|
||||
resourcePublicId: event.resourcePublicId ?? null,
|
||||
result: event.result,
|
||||
metadataJson: metadata ?? undefined,
|
||||
ipHash: request.ip ? this.sensitiveData.auditIpHash(request.ip) : null,
|
||||
userAgentSummary: userAgent,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
safeMetadata(metadata: AuditEvent['metadata']): Prisma.InputJsonObject | undefined {
|
||||
if (!metadata) return undefined;
|
||||
const entries = Object.entries(metadata).map(([key, value]) => {
|
||||
if (SENSITIVE_METADATA_KEY.test(key)) {
|
||||
throw new Error(`Sensitive audit metadata key is not allowed: ${key}`);
|
||||
}
|
||||
return [key, value] as const;
|
||||
});
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Body, Controller, Get, Inject, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
PERMISSION_CODES,
|
||||
type AdminLoginResponse,
|
||||
type CurrentUserResponse,
|
||||
type LogoutResponse,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminOriginGuard } from './admin-origin.guard';
|
||||
import { RequireSessionChannel } from './decorators/require-session-channel.decorator';
|
||||
import { AdminLoginDto } from './dto/admin-login.dto';
|
||||
import { ADMIN_SESSION_COOKIE } from './session.constants';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Controller('api/v1/admin/auth')
|
||||
export class AdminAuthController {
|
||||
constructor(
|
||||
@Inject(AdminAuthService) private readonly adminAuth: AdminAuthService,
|
||||
@Inject(SessionService) private readonly sessions: SessionService,
|
||||
@Inject(AppConfigService) private readonly config: AppConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@UseGuards(AdminOriginGuard)
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@Post('login')
|
||||
async login(
|
||||
@Body() input: AdminLoginDto,
|
||||
@Req() request: RequestContext,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<AdminLoginResponse> {
|
||||
const result = await this.adminAuth.login(input, request);
|
||||
response.cookie(ADMIN_SESSION_COOKIE, result.token, {
|
||||
httpOnly: true,
|
||||
secure: this.config.adminCookieSecure,
|
||||
sameSite: 'strict',
|
||||
path: '/api/v1',
|
||||
maxAge: this.config.adminSessionTtlSeconds * 1_000,
|
||||
});
|
||||
return result.response;
|
||||
}
|
||||
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
@RequirePermissions(PERMISSION_CODES.ADMIN_ACCESS)
|
||||
@Get('me')
|
||||
getCurrentAdmin(@CurrentAuth() auth: AuthContext): CurrentUserResponse {
|
||||
return { user: this.sessions.toPublicUser(auth) };
|
||||
}
|
||||
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
@UseGuards(AdminOriginGuard)
|
||||
@Post('logout')
|
||||
async logout(
|
||||
@CurrentAuth() auth: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
): Promise<LogoutResponse> {
|
||||
await this.adminAuth.logout(auth, request);
|
||||
response.clearCookie(ADMIN_SESSION_COOKIE, {
|
||||
httpOnly: true,
|
||||
secure: this.config.adminCookieSecure,
|
||||
sameSite: 'strict',
|
||||
path: '/api/v1',
|
||||
});
|
||||
return { loggedOut: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { API_ERROR_CODES, type AdminLoginResponse } from '@lehuo/contracts';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import type { AdminLoginDto } from './dto/admin-login.dto';
|
||||
import { PasswordService } from './password.service';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
const MAX_FAILED_LOGINS = 5;
|
||||
const LOCK_DURATION_MS = 15 * 60 * 1_000;
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||
@Inject(SessionService) private readonly sessions: SessionService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async login(
|
||||
input: AdminLoginDto,
|
||||
request: RequestContext,
|
||||
): Promise<{ token: string; response: AdminLoginResponse }> {
|
||||
const credential = await this.prisma.adminCredential.findUnique({
|
||||
where: { username: input.username },
|
||||
include: { user: true },
|
||||
});
|
||||
const passwordValid = await this.passwords.verifyOrDummy(
|
||||
credential?.passwordHash ?? null,
|
||||
input.password,
|
||||
);
|
||||
if (!credential || !passwordValid) {
|
||||
await this.recordFailedLogin(
|
||||
credential?.id,
|
||||
credential?.userId,
|
||||
request,
|
||||
'INVALID_CREDENTIALS',
|
||||
);
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.INVALID_CREDENTIALS,
|
||||
'用户名或密码不正确',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
if (credential.lockedUntil && credential.lockedUntil.getTime() > Date.now()) {
|
||||
await this.audit.record(request, {
|
||||
action: 'AUTH_ADMIN_LOGIN',
|
||||
result: 'FAILURE',
|
||||
actorUserId: credential.userId,
|
||||
resourceType: 'USER',
|
||||
resourcePublicId: credential.user.publicId,
|
||||
metadata: { reasonCode: 'ACCOUNT_LOCKED' },
|
||||
});
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.ACCOUNT_LOCKED,
|
||||
'账户暂时锁定,请稍后再试',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
if (credential.user.status !== 'ACTIVE') {
|
||||
await this.audit.record(request, {
|
||||
action: 'AUTH_ADMIN_LOGIN',
|
||||
result: 'FAILURE',
|
||||
actorUserId: credential.userId,
|
||||
resourceType: 'USER',
|
||||
resourcePublicId: credential.user.publicId,
|
||||
metadata: { reasonCode: 'ACCOUNT_DISABLED' },
|
||||
});
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.ACCOUNT_DISABLED,
|
||||
'账户当前不可用,请联系管理员',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
const session = await this.prisma.$transaction(async (transaction) => {
|
||||
await transaction.adminCredential.update({
|
||||
where: { id: credential.id },
|
||||
data: { failedLoginCount: 0, lockedUntil: null },
|
||||
});
|
||||
await transaction.user.update({
|
||||
where: { id: credential.userId },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
const created = await this.sessions.createRotatedSession(
|
||||
transaction,
|
||||
credential.userId,
|
||||
SessionChannel.ADMIN,
|
||||
);
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'AUTH_ADMIN_LOGIN',
|
||||
result: 'SUCCESS',
|
||||
actorUserId: credential.userId,
|
||||
resourceType: 'USER',
|
||||
resourcePublicId: credential.user.publicId,
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
const auth = await this.sessions.authenticate(session.token);
|
||||
return {
|
||||
token: session.token,
|
||||
response: {
|
||||
user: this.sessions.toPublicUser(auth),
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async logout(auth: AuthContext, request: RequestContext): Promise<void> {
|
||||
await this.prisma.$transaction(async (transaction) => {
|
||||
await transaction.authSession.updateMany({
|
||||
where: { id: auth.sessionId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'AUTH_ADMIN_LOGOUT',
|
||||
result: 'SUCCESS',
|
||||
actor: auth,
|
||||
resourceType: 'USER',
|
||||
resourcePublicId: auth.userPublicId,
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async recordFailedLogin(
|
||||
credentialId: bigint | undefined,
|
||||
userId: bigint | undefined,
|
||||
request: RequestContext,
|
||||
reasonCode: string,
|
||||
): Promise<void> {
|
||||
await this.prisma.$transaction(async (transaction) => {
|
||||
if (credentialId) {
|
||||
const current = await transaction.adminCredential.findUniqueOrThrow({
|
||||
where: { id: credentialId },
|
||||
});
|
||||
const failures = current.failedLoginCount + 1;
|
||||
await transaction.adminCredential.update({
|
||||
where: { id: credentialId },
|
||||
data: {
|
||||
failedLoginCount: failures,
|
||||
lockedUntil:
|
||||
failures >= MAX_FAILED_LOGINS ? new Date(Date.now() + LOCK_DURATION_MS) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'AUTH_ADMIN_LOGIN',
|
||||
result: 'FAILURE',
|
||||
...(userId ? { actorUserId: userId } : {}),
|
||||
resourceType: 'USER',
|
||||
metadata: { reasonCode },
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type { RequestContext } from '../common/request-context';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminOriginGuard implements CanActivate {
|
||||
constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<RequestContext>();
|
||||
if (request.header('origin') !== this.config.adminWebOrigin) throw new ForbiddenException();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Body, Controller, Get, Inject, Post, Req } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import type { CurrentUserResponse, LogoutResponse, WechatLoginResponse } from '@lehuo/contracts';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { RequireSessionChannel } from './decorators/require-session-channel.decorator';
|
||||
import { WechatLoginDto } from './dto/wechat-login.dto';
|
||||
import { SessionService } from './session.service';
|
||||
import { WechatAuthService } from './wechat-auth.service';
|
||||
|
||||
@Controller('api/v1')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
@Inject(WechatAuthService) private readonly wechatAuth: WechatAuthService,
|
||||
@Inject(SessionService) private readonly sessions: SessionService,
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@Post('auth/wechat/login')
|
||||
loginWithWechat(
|
||||
@Body() input: WechatLoginDto,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<WechatLoginResponse> {
|
||||
return this.wechatAuth.login(input, request);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
getCurrentUser(@CurrentAuth() auth: AuthContext): CurrentUserResponse {
|
||||
return { user: this.sessions.toPublicUser(auth) };
|
||||
}
|
||||
|
||||
@RequireSessionChannel(SessionChannel.WECHAT)
|
||||
@Post('auth/logout')
|
||||
async logout(
|
||||
@CurrentAuth() auth: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<LogoutResponse> {
|
||||
await this.prisma.$transaction(async (transaction) => {
|
||||
await transaction.authSession.updateMany({
|
||||
where: { id: auth.sessionId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'AUTH_WECHAT_LOGOUT',
|
||||
result: 'SUCCESS',
|
||||
actor: auth,
|
||||
resourceType: 'USER',
|
||||
resourcePublicId: auth.userPublicId,
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
});
|
||||
return { loggedOut: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AdminAuthController } from './admin-auth.controller';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminOriginGuard } from './admin-origin.guard';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { PasswordService } from './password.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { WECHAT_AUTH_ADAPTER } from './wechat-auth.adapter';
|
||||
import { WechatAuthService } from './wechat-auth.service';
|
||||
import { WechatHttpAdapter } from './wechat-http.adapter';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [AuthController, AdminAuthController],
|
||||
providers: [
|
||||
SessionService,
|
||||
PasswordService,
|
||||
WechatAuthService,
|
||||
AdminAuthService,
|
||||
AdminOriginGuard,
|
||||
WechatHttpAdapter,
|
||||
{ provide: WECHAT_AUTH_ADAPTER, useExisting: WechatHttpAdapter },
|
||||
],
|
||||
exports: [SessionService, PasswordService, AdminOriginGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import type { SessionChannel } from '../generated/prisma/enums';
|
||||
import { IS_PUBLIC_KEY } from '../common/decorators/public.decorator';
|
||||
import type { RequestContext } from '../common/request-context';
|
||||
import { REQUIRED_SESSION_CHANNEL_KEY } from './decorators/require-session-channel.decorator';
|
||||
import { ADMIN_SESSION_COOKIE } from './session.constants';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthenticationGuard implements CanActivate {
|
||||
constructor(
|
||||
@Inject(Reflector) private readonly reflector: Reflector,
|
||||
@Inject(SessionService) private readonly sessions: SessionService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
const request = context.switchToHttp().getRequest<RequestContext>();
|
||||
const token = this.extractBearerToken(request) ?? this.extractAdminCookie(request);
|
||||
if (!token) throw new UnauthorizedException();
|
||||
const auth = await this.sessions.authenticate(token);
|
||||
const requiredChannel = this.reflector.getAllAndOverride<SessionChannel>(
|
||||
REQUIRED_SESSION_CHANNEL_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (requiredChannel && auth.channel !== requiredChannel) throw new UnauthorizedException();
|
||||
request.auth = auth;
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractBearerToken(request: RequestContext): string | null {
|
||||
const authorization = request.header('authorization');
|
||||
if (!authorization) return null;
|
||||
const match = /^Bearer ([A-Za-z0-9_-]{32,128})$/.exec(authorization);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
private extractAdminCookie(request: RequestContext): string | null {
|
||||
const cookies = request.cookies as Record<string, unknown> | undefined;
|
||||
const value = cookies?.[ADMIN_SESSION_COOKIE];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
import type { SessionChannel } from '../../generated/prisma/enums';
|
||||
|
||||
export const REQUIRED_SESSION_CHANNEL_KEY = 'requiredSessionChannel';
|
||||
export const RequireSessionChannel = (channel: SessionChannel): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(REQUIRED_SESSION_CHANNEL_KEY, channel);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
import type { AdminLoginRequest } from '@lehuo/contracts';
|
||||
|
||||
export class AdminLoginDto implements AdminLoginRequest {
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||
)
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9._-]+$/)
|
||||
@Length(3, 64)
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(12, 128)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
import type { WechatLoginRequest } from '@lehuo/contracts';
|
||||
|
||||
export class WechatLoginDto implements WechatLoginRequest {
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z0-9_-]{1,128}$/)
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PasswordService } from './password.service';
|
||||
|
||||
describe('PasswordService', () => {
|
||||
it('hashes and verifies passwords with Argon2id', async () => {
|
||||
const service = new PasswordService();
|
||||
const passwordHash = await service.hash('correct horse battery staple');
|
||||
|
||||
await expect(service.verifyOrDummy(passwordHash, 'correct horse battery staple')).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
await expect(service.verifyOrDummy(passwordHash, 'incorrect password')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('performs a dummy verification for unknown accounts', async () => {
|
||||
await expect(
|
||||
new PasswordService().verifyOrDummy(null, 'unknown account password'),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { hash, verify, argon2id } from 'argon2';
|
||||
|
||||
const ARGON_OPTIONS = {
|
||||
type: argon2id,
|
||||
memoryCost: 19_456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class PasswordService {
|
||||
private readonly dummyHash = hash('not-a-real-account-password', ARGON_OPTIONS);
|
||||
|
||||
hash(password: string): Promise<string> {
|
||||
return hash(password, ARGON_OPTIONS);
|
||||
}
|
||||
|
||||
async verifyOrDummy(passwordHash: string | null, password: string): Promise<boolean> {
|
||||
const selectedHash = passwordHash ?? (await this.dummyHash);
|
||||
const valid = await verify(selectedHash, password);
|
||||
return passwordHash !== null && valid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const ADMIN_SESSION_COOKIE = 'lehuo_admin_session';
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
PERMISSION_CODES,
|
||||
ROLE_CODES,
|
||||
USER_STATUSES,
|
||||
USER_TYPES,
|
||||
type AuthUser,
|
||||
type PermissionCode,
|
||||
type RoleCode,
|
||||
type UserStatus,
|
||||
type UserType,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import type { Prisma } from '../generated/prisma/client';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import type { AuthContext } from '../common/request-context';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
|
||||
const PERMISSIONS = Object.values(PERMISSION_CODES) as string[];
|
||||
|
||||
function isRoleCode(value: string): value is RoleCode {
|
||||
return (ROLE_CODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isPermissionCode(value: string): value is PermissionCode {
|
||||
return PERMISSIONS.includes(value);
|
||||
}
|
||||
|
||||
function isUserStatus(value: string): value is UserStatus {
|
||||
return (USER_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isUserType(value: string): value is UserType {
|
||||
return (USER_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(AppConfigService) private readonly config: AppConfigService,
|
||||
@Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
|
||||
) {}
|
||||
|
||||
async createRotatedSession(
|
||||
client: Prisma.TransactionClient,
|
||||
userId: bigint,
|
||||
channel: SessionChannel,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const ttlSeconds =
|
||||
channel === SessionChannel.ADMIN
|
||||
? this.config.adminSessionTtlSeconds
|
||||
: this.config.wechatSessionTtlSeconds;
|
||||
const expiresAt = new Date(Date.now() + ttlSeconds * 1_000);
|
||||
await client.authSession.updateMany({
|
||||
where: { userId, channel, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await client.authSession.create({
|
||||
data: {
|
||||
publicId: randomUUID(),
|
||||
userId,
|
||||
channel,
|
||||
tokenHash: this.sensitiveData.sessionTokenHash(token),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
async authenticate(token: string): Promise<AuthContext> {
|
||||
const session = await this.prisma.authSession.findUnique({
|
||||
where: { tokenHash: this.sensitiveData.sessionTokenHash(token) },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
staffProfile: true,
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
rolePermissions: { include: { permission: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (
|
||||
!session ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt.getTime() <= Date.now() ||
|
||||
session.user.status !== 'ACTIVE'
|
||||
) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const roles = session.user.userRoles.map(({ role }) => role.code).filter(isRoleCode);
|
||||
const permissions = [
|
||||
...new Set(
|
||||
session.user.userRoles.flatMap(({ role }) =>
|
||||
role.rolePermissions.map(({ permission }) => permission.code),
|
||||
),
|
||||
),
|
||||
].filter(isPermissionCode);
|
||||
if (!isUserStatus(session.user.status) || !isUserType(session.user.userType)) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
await this.prisma.authSession.update({
|
||||
where: { id: session.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
return {
|
||||
userId: session.user.id,
|
||||
userPublicId: session.user.publicId,
|
||||
status: session.user.status,
|
||||
userType: session.user.userType,
|
||||
displayName: session.user.staffProfile?.displayName ?? session.user.nickname,
|
||||
roles,
|
||||
permissions,
|
||||
sessionId: session.id,
|
||||
channel: session.channel,
|
||||
rawToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
async revoke(auth: AuthContext): Promise<void> {
|
||||
await this.prisma.authSession.updateMany({
|
||||
where: { id: auth.sessionId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
toPublicUser(auth: AuthContext): AuthUser {
|
||||
return {
|
||||
publicId: auth.userPublicId,
|
||||
status: auth.status,
|
||||
userType: auth.userType,
|
||||
displayName: auth.displayName,
|
||||
roles: auth.roles,
|
||||
permissions: auth.permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const WECHAT_AUTH_ADAPTER = Symbol('WECHAT_AUTH_ADAPTER');
|
||||
|
||||
export interface WechatIdentityResult {
|
||||
openid: string;
|
||||
unionid?: string;
|
||||
}
|
||||
|
||||
export interface WechatAuthAdapter {
|
||||
exchangeCode(code: string): Promise<WechatIdentityResult>;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { API_ERROR_CODES, type WechatLoginResponse } from '@lehuo/contracts';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { RequestContext } from '../common/request-context';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type { WechatLoginDto } from './dto/wechat-login.dto';
|
||||
import { SessionService } from './session.service';
|
||||
import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from './wechat-auth.adapter';
|
||||
|
||||
@Injectable()
|
||||
export class WechatAuthService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(AppConfigService) private readonly config: AppConfigService,
|
||||
@Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
|
||||
@Inject(SessionService) private readonly sessions: SessionService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
@Inject(WECHAT_AUTH_ADAPTER) private readonly adapter: WechatAuthAdapter,
|
||||
) {}
|
||||
|
||||
async login(input: WechatLoginDto, request: RequestContext): Promise<WechatLoginResponse> {
|
||||
let identityResult;
|
||||
try {
|
||||
identityResult = await this.adapter.exchangeCode(input.code);
|
||||
} catch (error) {
|
||||
await this.audit.record(request, {
|
||||
action: 'AUTH_WECHAT_LOGIN',
|
||||
result: 'FAILURE',
|
||||
resourceType: 'USER',
|
||||
metadata: { reasonCode: 'WECHAT_EXCHANGE_FAILED' },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (!this.config.wechatAppId) {
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.WECHAT_LOGIN_FAILED,
|
||||
'微信登录暂时不可用,请稍后重试',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
const openidHash = this.sensitiveData.lookupHash('wechat-openid', identityResult.openid);
|
||||
const unionidHash = identityResult.unionid
|
||||
? this.sensitiveData.lookupHash('wechat-unionid', identityResult.unionid)
|
||||
: null;
|
||||
const result = await this.prisma.$transaction(async (transaction) => {
|
||||
let identity = await transaction.wechatIdentity.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ appid: this.config.wechatAppId!, openidHash },
|
||||
...(unionidHash ? [{ unionidHash }] : []),
|
||||
],
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
if (!identity) {
|
||||
const user = await transaction.user.create({
|
||||
data: { publicId: randomUUID(), userType: 'PUBLIC' },
|
||||
});
|
||||
identity = await transaction.wechatIdentity.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
appid: this.config.wechatAppId!,
|
||||
openidHash,
|
||||
openidCiphertext: this.sensitiveData.encrypt(identityResult.openid),
|
||||
unionidHash,
|
||||
unionidCiphertext: identityResult.unionid
|
||||
? this.sensitiveData.encrypt(identityResult.unionid)
|
||||
: null,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
} else if (identityResult.unionid && !identity.unionidHash) {
|
||||
identity = await transaction.wechatIdentity.update({
|
||||
where: { id: identity.id },
|
||||
data: {
|
||||
unionidHash,
|
||||
unionidCiphertext: this.sensitiveData.encrypt(identityResult.unionid),
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
if (identity.user.status !== 'ACTIVE') {
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.ACCOUNT_DISABLED,
|
||||
'账户当前不可用,请联系客服',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
await transaction.user.update({
|
||||
where: { id: identity.userId },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
const session = await this.sessions.createRotatedSession(
|
||||
transaction,
|
||||
identity.userId,
|
||||
SessionChannel.WECHAT,
|
||||
);
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'AUTH_WECHAT_LOGIN',
|
||||
result: 'SUCCESS',
|
||||
actorUserId: identity.userId,
|
||||
resourceType: 'USER',
|
||||
resourcePublicId: identity.user.publicId,
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
return session;
|
||||
});
|
||||
const auth = await this.sessions.authenticate(result.token);
|
||||
return {
|
||||
accessToken: result.token,
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: result.expiresAt.toISOString(),
|
||||
user: this.sessions.toPublicUser(auth),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { API_ERROR_CODES } from '@lehuo/contracts';
|
||||
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import type { WechatAuthAdapter, WechatIdentityResult } from './wechat-auth.adapter';
|
||||
|
||||
interface WechatCodeResponse {
|
||||
openid?: string;
|
||||
unionid?: string;
|
||||
errcode?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WechatHttpAdapter implements WechatAuthAdapter {
|
||||
constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {}
|
||||
|
||||
async exchangeCode(code: string): Promise<WechatIdentityResult> {
|
||||
if (!this.config.wechatAppId || !this.config.wechatAppSecret) {
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.WECHAT_LOGIN_FAILED,
|
||||
'微信登录暂时不可用,请稍后重试',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
const endpoint = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||
endpoint.search = new URLSearchParams({
|
||||
appid: this.config.wechatAppId,
|
||||
secret: this.config.wechatAppSecret,
|
||||
js_code: code,
|
||||
grant_type: 'authorization_code',
|
||||
}).toString();
|
||||
try {
|
||||
const response = await fetch(endpoint, { signal: AbortSignal.timeout(5_000) });
|
||||
if (!response.ok) throw new Error('wechat-http-status');
|
||||
const payload = (await response.json()) as WechatCodeResponse;
|
||||
if (payload.errcode || !payload.openid) throw new Error('wechat-code-rejected');
|
||||
return {
|
||||
openid: payload.openid,
|
||||
...(payload.unionid ? { unionid: payload.unionid } : {}),
|
||||
};
|
||||
} catch {
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.WECHAT_LOGIN_FAILED,
|
||||
'微信登录失败,请稍后重试',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Body, Controller, Inject, Param, Post, Req, ValidationPipe } from '@nestjs/common';
|
||||
|
||||
import { PERMISSION_CODES, type AcceptServiceApplicationResponse } from '@lehuo/contracts';
|
||||
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { CaseManagementService } from './case-management.service';
|
||||
import {
|
||||
AcceptServiceApplicationDto,
|
||||
TransitionServiceApplicationDto,
|
||||
} from './dto/case-management.dto';
|
||||
|
||||
@Controller('api/v1/admin/service-applications')
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
@RequirePermissions(PERMISSION_CODES.APPLICATIONS_MANAGE)
|
||||
export class AdminApplicationWorkflowController {
|
||||
constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {}
|
||||
|
||||
@Post(':publicId/transition')
|
||||
transition(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: TransitionServiceApplicationDto,
|
||||
}),
|
||||
)
|
||||
input: TransitionServiceApplicationDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<{ status: string }> {
|
||||
return this.cases.transitionApplication(publicId, input, actor, request);
|
||||
}
|
||||
|
||||
@Post(':publicId/accept')
|
||||
accept(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: AcceptServiceApplicationDto,
|
||||
}),
|
||||
)
|
||||
input: AcceptServiceApplicationDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<AcceptServiceApplicationResponse> {
|
||||
return this.cases.acceptApplication(publicId, input, actor, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import {
|
||||
PERMISSION_CODES,
|
||||
type BeneficiaryDetailResponse,
|
||||
type BeneficiaryTagOption,
|
||||
type PaginatedBeneficiariesResponse,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { CaseManagementService } from './case-management.service';
|
||||
import { BeneficiaryQueryDto, UpdateBeneficiaryDto } from './dto/case-management.dto';
|
||||
|
||||
@Controller('api/v1/admin/beneficiaries')
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
@RequirePermissions(PERMISSION_CODES.BENEFICIARIES_READ)
|
||||
export class AdminBeneficiariesController {
|
||||
constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {}
|
||||
|
||||
@Get('tags')
|
||||
tags(): Promise<BeneficiaryTagOption[]> {
|
||||
return this.cases.tags();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: BeneficiaryQueryDto,
|
||||
}),
|
||||
)
|
||||
query: BeneficiaryQueryDto,
|
||||
): Promise<PaginatedBeneficiariesResponse> {
|
||||
return this.cases.listBeneficiaries(query);
|
||||
}
|
||||
|
||||
@Get(':publicId')
|
||||
get(
|
||||
@Param('publicId') publicId: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<BeneficiaryDetailResponse> {
|
||||
return this.cases.getBeneficiary(publicId, actor, request);
|
||||
}
|
||||
|
||||
@Put(':publicId')
|
||||
@RequirePermissions(PERMISSION_CODES.BENEFICIARIES_MANAGE)
|
||||
update(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: UpdateBeneficiaryDto,
|
||||
}),
|
||||
)
|
||||
input: UpdateBeneficiaryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<BeneficiaryDetailResponse> {
|
||||
return this.cases.updateBeneficiary(publicId, input, actor, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import {
|
||||
PERMISSION_CODES,
|
||||
type AssigneeOption,
|
||||
type PaginatedServiceCasesResponse,
|
||||
type ServiceCaseDetailResponse,
|
||||
type ServiceRecordResponse,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { CaseManagementService } from './case-management.service';
|
||||
import {
|
||||
CreateServiceRecordDto,
|
||||
ReplaceCaseAssigneesDto,
|
||||
ServiceCaseQueryDto,
|
||||
UpdateServiceRecordDto,
|
||||
} from './dto/case-management.dto';
|
||||
|
||||
@Controller('api/v1/admin/service-cases')
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
@RequirePermissions(PERMISSION_CODES.CASES_READ)
|
||||
export class AdminServiceCasesController {
|
||||
constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {}
|
||||
|
||||
@Get('assignee-options')
|
||||
@RequirePermissions(PERMISSION_CODES.CASES_ASSIGN)
|
||||
assignees(): Promise<AssigneeOption[]> {
|
||||
return this.cases.assigneeOptions();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: ServiceCaseQueryDto,
|
||||
}),
|
||||
)
|
||||
query: ServiceCaseQueryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
): Promise<PaginatedServiceCasesResponse> {
|
||||
return this.cases.listCases(query, actor);
|
||||
}
|
||||
|
||||
@Get(':publicId')
|
||||
get(
|
||||
@Param('publicId') publicId: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<ServiceCaseDetailResponse> {
|
||||
return this.cases.getCase(publicId, actor, request);
|
||||
}
|
||||
|
||||
@Post(':publicId/start')
|
||||
@RequirePermissions(PERMISSION_CODES.CASES_MANAGE)
|
||||
start(
|
||||
@Param('publicId') publicId: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<ServiceCaseDetailResponse> {
|
||||
return this.cases.startCase(publicId, actor, request);
|
||||
}
|
||||
|
||||
@Put(':publicId/assignees')
|
||||
@RequirePermissions(PERMISSION_CODES.CASES_ASSIGN)
|
||||
replaceAssignees(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: ReplaceCaseAssigneesDto,
|
||||
}),
|
||||
)
|
||||
input: ReplaceCaseAssigneesDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<ServiceCaseDetailResponse> {
|
||||
return this.cases.replaceAssignees(publicId, input, actor, request);
|
||||
}
|
||||
|
||||
@Post(':publicId/records')
|
||||
@RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE)
|
||||
createRecord(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: CreateServiceRecordDto,
|
||||
}),
|
||||
)
|
||||
input: CreateServiceRecordDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<ServiceRecordResponse> {
|
||||
return this.cases.createRecord(publicId, input, actor, request);
|
||||
}
|
||||
|
||||
@Patch(':publicId/records/:recordPublicId')
|
||||
@RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE)
|
||||
updateRecord(
|
||||
@Param('publicId') publicId: string,
|
||||
@Param('recordPublicId') recordPublicId: string,
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: UpdateServiceRecordDto,
|
||||
}),
|
||||
)
|
||||
input: UpdateServiceRecordDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<ServiceRecordResponse> {
|
||||
return this.cases.updateRecord(publicId, recordPublicId, input, actor, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import {
|
||||
PERMISSION_CODES,
|
||||
type PaginatedServiceCasesResponse,
|
||||
type ServiceCaseDetailResponse,
|
||||
type ServiceRecordResponse,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { CaseManagementService } from './case-management.service';
|
||||
import {
|
||||
CreateServiceRecordDto,
|
||||
ServiceCaseQueryDto,
|
||||
UpdateServiceRecordDto,
|
||||
} from './dto/case-management.dto';
|
||||
|
||||
@Controller('api/v1/service-cases')
|
||||
@RequireSessionChannel(SessionChannel.WECHAT)
|
||||
@RequirePermissions(PERMISSION_CODES.ASSIGNED_CASES_READ)
|
||||
export class AssignedServiceCasesController {
|
||||
constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: ServiceCaseQueryDto,
|
||||
}),
|
||||
)
|
||||
query: ServiceCaseQueryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
): Promise<PaginatedServiceCasesResponse> {
|
||||
return this.cases.listCases(query, actor);
|
||||
}
|
||||
|
||||
@Get(':publicId')
|
||||
get(
|
||||
@Param('publicId') publicId: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<ServiceCaseDetailResponse> {
|
||||
return this.cases.getCase(publicId, actor, request, true);
|
||||
}
|
||||
|
||||
@Post(':publicId/records')
|
||||
@RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE)
|
||||
createRecord(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: CreateServiceRecordDto,
|
||||
}),
|
||||
)
|
||||
input: CreateServiceRecordDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<ServiceRecordResponse> {
|
||||
return this.cases.createRecord(publicId, input, actor, request);
|
||||
}
|
||||
|
||||
@Patch(':publicId/records/:recordPublicId')
|
||||
@RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE)
|
||||
updateRecord(
|
||||
@Param('publicId') publicId: string,
|
||||
@Param('recordPublicId') recordPublicId: string,
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: UpdateServiceRecordDto,
|
||||
}),
|
||||
)
|
||||
input: UpdateServiceRecordDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<ServiceRecordResponse> {
|
||||
return this.cases.updateRecord(publicId, recordPublicId, input, actor, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Body, Controller, Get, Inject, Param, Post, Req, ValidationPipe } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
PERMISSION_CODES,
|
||||
type ConfirmUploadResponse,
|
||||
type CreateUploadIntentResponse,
|
||||
type DownloadUrlResponse,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { CreateUploadIntentDto } from './dto/case-management.dto';
|
||||
|
||||
@Controller('api/v1')
|
||||
@RequirePermissions(PERMISSION_CODES.FILES_WRITE)
|
||||
export class CaseFilesController {
|
||||
constructor(@Inject(FilesService) private readonly files: FilesService) {}
|
||||
|
||||
@Post('service-cases/:casePublicId/files/upload-intents')
|
||||
createIntent(
|
||||
@Param('casePublicId') casePublicId: string,
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: CreateUploadIntentDto,
|
||||
}),
|
||||
)
|
||||
input: CreateUploadIntentDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<CreateUploadIntentResponse> {
|
||||
return this.files.createUploadIntent(casePublicId, input, actor, request);
|
||||
}
|
||||
|
||||
@Post('files/:publicId/confirm')
|
||||
confirm(
|
||||
@Param('publicId') publicId: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<ConfirmUploadResponse> {
|
||||
return this.files.confirm(publicId, actor, request);
|
||||
}
|
||||
|
||||
@Get('files/:publicId/download-url')
|
||||
download(
|
||||
@Param('publicId') publicId: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<DownloadUrlResponse> {
|
||||
return this.files.downloadUrl(publicId, actor, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { ContentNotificationsModule } from '../content-notifications/content-notifications.module';
|
||||
import { AdminApplicationWorkflowController } from './admin-application-workflow.controller';
|
||||
import { AdminBeneficiariesController } from './admin-beneficiaries.controller';
|
||||
import { AdminServiceCasesController } from './admin-service-cases.controller';
|
||||
import { AssignedServiceCasesController } from './assigned-service-cases.controller';
|
||||
import { CaseFilesController } from './case-files.controller';
|
||||
import { CaseManagementRepository } from './case-management.repository';
|
||||
import { CaseManagementService } from './case-management.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, FilesModule, ContentNotificationsModule],
|
||||
controllers: [
|
||||
AdminApplicationWorkflowController,
|
||||
AdminBeneficiariesController,
|
||||
AdminServiceCasesController,
|
||||
AssignedServiceCasesController,
|
||||
CaseFilesController,
|
||||
],
|
||||
providers: [CaseManagementRepository, CaseManagementService, FilesService],
|
||||
})
|
||||
export class CaseManagementModule {}
|
||||
@@ -0,0 +1,856 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
BeneficiaryConfirmStatus,
|
||||
BeneficiaryGender,
|
||||
BeneficiaryStatus,
|
||||
ServiceCasePriority,
|
||||
ServiceCaseStatus,
|
||||
ServiceCategory,
|
||||
ServiceRecordType,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import type { Prisma } from '../generated/prisma/client';
|
||||
import { NotificationsService } from '../content-notifications/notifications.service';
|
||||
|
||||
const beneficiaryInclude = {
|
||||
contacts: { where: { removedAt: null }, orderBy: { createdAt: 'asc' as const } },
|
||||
beneficiaryTags: { include: { tag: true }, orderBy: { assignedAt: 'asc' as const } },
|
||||
_count: { select: { serviceCases: true } },
|
||||
} satisfies Prisma.BeneficiaryProfileInclude;
|
||||
|
||||
const caseInclude = {
|
||||
application: true,
|
||||
beneficiary: { include: beneficiaryInclude },
|
||||
owner: { include: { staffProfile: true } },
|
||||
assignees: {
|
||||
where: { removedAt: null },
|
||||
include: { user: { include: { staffProfile: true } } },
|
||||
orderBy: [{ role: 'asc' as const }, { assignedAt: 'asc' as const }],
|
||||
},
|
||||
statusHistory: { orderBy: { createdAt: 'asc' as const } },
|
||||
serviceRecords: {
|
||||
include: {
|
||||
creator: { include: { staffProfile: true } },
|
||||
attachments: { include: { file: true } },
|
||||
},
|
||||
orderBy: [{ serviceStartAt: 'desc' as const }, { id: 'desc' as const }],
|
||||
},
|
||||
} satisfies Prisma.ServiceCaseInclude;
|
||||
|
||||
export type StoredBeneficiary = Prisma.BeneficiaryProfileGetPayload<{
|
||||
include: typeof beneficiaryInclude;
|
||||
}>;
|
||||
export type StoredServiceCase = Prisma.ServiceCaseGetPayload<{ include: typeof caseInclude }>;
|
||||
|
||||
export interface PreparedContact {
|
||||
publicId: string;
|
||||
nameEncrypted: string;
|
||||
relation: string;
|
||||
mobileEncrypted: string;
|
||||
mobileHash: string;
|
||||
isGuardian: boolean;
|
||||
isEmergencyContact: boolean;
|
||||
}
|
||||
|
||||
export interface PreparedBeneficiary {
|
||||
publicId: string;
|
||||
displayNameEncrypted: string;
|
||||
displayNameHash: string;
|
||||
gender?: BeneficiaryGender;
|
||||
birthYear?: number;
|
||||
mobileEncrypted?: string;
|
||||
mobileHash?: string;
|
||||
regionCode: string;
|
||||
addressDetailEncrypted?: string;
|
||||
primaryCategory: ServiceCategory;
|
||||
keyCareFlag: boolean;
|
||||
isMinor: boolean;
|
||||
notesEncrypted?: string;
|
||||
contacts: PreparedContact[];
|
||||
tagCodes: string[];
|
||||
guardianConsentConfirmed: boolean;
|
||||
}
|
||||
|
||||
export interface AcceptApplicationInput {
|
||||
applicationPublicId: string;
|
||||
acceptRequestId: string;
|
||||
acceptRequestFingerprint: string;
|
||||
existingBeneficiaryId?: bigint;
|
||||
newBeneficiary?: PreparedBeneficiary;
|
||||
ownerStaffId: bigint;
|
||||
collaborators: Array<{ userId: bigint; type: 'STAFF' | 'VOLUNTEER' }>;
|
||||
casePublicId: string;
|
||||
caseNo: string;
|
||||
priority: ServiceCasePriority;
|
||||
plannedStartAt?: Date;
|
||||
plannedEndAt?: Date;
|
||||
serviceLocationEncrypted?: string;
|
||||
summaryEncrypted: string;
|
||||
}
|
||||
|
||||
export interface BeneficiaryFilters {
|
||||
primaryCategory?: ServiceCategory;
|
||||
status?: BeneficiaryStatus;
|
||||
keyCareFlag?: boolean;
|
||||
}
|
||||
|
||||
export interface CaseFilters {
|
||||
status?: ServiceCaseStatus;
|
||||
category?: ServiceCategory;
|
||||
caseNo?: string;
|
||||
}
|
||||
|
||||
export interface PreparedServiceRecord {
|
||||
publicId: string;
|
||||
clientRequestId: string;
|
||||
requestFingerprint: string;
|
||||
serviceStartAt: Date;
|
||||
serviceEndAt?: Date;
|
||||
serviceType: ServiceRecordType;
|
||||
contentEncrypted: string;
|
||||
resultEncrypted: string;
|
||||
followUpSuggestionEncrypted?: string;
|
||||
beneficiaryConfirmStatus: BeneficiaryConfirmStatus;
|
||||
attachmentPublicIds: string[];
|
||||
}
|
||||
|
||||
export interface PreparedBeneficiaryUpdate extends PreparedBeneficiary {
|
||||
status: BeneficiaryStatus;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CaseManagementRepository {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
@Inject(NotificationsService) private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
findApplication(publicId: string) {
|
||||
return this.prisma.serviceApplication.findUnique({
|
||||
where: { publicId },
|
||||
include: { serviceCase: { include: caseInclude } },
|
||||
});
|
||||
}
|
||||
|
||||
findBeneficiary(publicId: string): Promise<StoredBeneficiary | null> {
|
||||
return this.prisma.beneficiaryProfile.findUnique({
|
||||
where: { publicId },
|
||||
include: beneficiaryInclude,
|
||||
});
|
||||
}
|
||||
|
||||
async listBeneficiaries(filters: BeneficiaryFilters, page: number, pageSize: number) {
|
||||
const where: Prisma.BeneficiaryProfileWhereInput = {
|
||||
...(filters.primaryCategory ? { primaryCategory: filters.primaryCategory } : {}),
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.keyCareFlag === undefined ? {} : { keyCareFlag: filters.keyCareFlag }),
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.beneficiaryProfile.findMany({
|
||||
where,
|
||||
include: beneficiaryInclude,
|
||||
orderBy: [{ keyCareFlag: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.beneficiaryProfile.count({ where }),
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async updateBeneficiary(
|
||||
existing: StoredBeneficiary,
|
||||
input: PreparedBeneficiaryUpdate,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<StoredBeneficiary> {
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const tags = await transaction.tag.findMany({ where: { code: { in: input.tagCodes } } });
|
||||
if (tags.length !== new Set(input.tagCodes).size) throw new Error('Unknown beneficiary tag');
|
||||
await transaction.beneficiaryProfile.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
displayNameEncrypted: input.displayNameEncrypted,
|
||||
displayNameHash: input.displayNameHash,
|
||||
gender: input.gender,
|
||||
birthYear: input.birthYear,
|
||||
mobileEncrypted: input.mobileEncrypted,
|
||||
mobileHash: input.mobileHash,
|
||||
regionCode: input.regionCode,
|
||||
addressDetailEncrypted: input.addressDetailEncrypted,
|
||||
keyCareFlag: input.keyCareFlag,
|
||||
isMinor: input.isMinor,
|
||||
status: input.status,
|
||||
notesEncrypted: input.notesEncrypted,
|
||||
updatedById: actor.userId,
|
||||
},
|
||||
});
|
||||
await transaction.beneficiaryContact.updateMany({
|
||||
where: { beneficiaryId: existing.id, removedAt: null },
|
||||
data: { removedAt: new Date() },
|
||||
});
|
||||
for (const contact of input.contacts) {
|
||||
let consentRecordId: bigint | undefined;
|
||||
if (input.isMinor && contact.isGuardian && input.guardianConsentConfirmed) {
|
||||
const consent = await transaction.consent.create({
|
||||
data: {
|
||||
publicId: crypto.randomUUID(),
|
||||
beneficiaryId: existing.id,
|
||||
consentType: 'BENEFICIARY_GUARDIAN',
|
||||
version: 'beneficiary-guardian-v1',
|
||||
grantedBy: 'GUARDIAN',
|
||||
grantedAt: new Date(),
|
||||
evidenceJson: { source: 'ADMIN_STAFF_RECORDED', operation: 'PROFILE_UPDATE' },
|
||||
},
|
||||
});
|
||||
consentRecordId = consent.id;
|
||||
}
|
||||
await transaction.beneficiaryContact.create({
|
||||
data: { ...contact, beneficiaryId: existing.id, consentRecordId },
|
||||
});
|
||||
}
|
||||
await transaction.beneficiaryTag.deleteMany({ where: { beneficiaryId: existing.id } });
|
||||
if (tags.length) {
|
||||
await transaction.beneficiaryTag.createMany({
|
||||
data: tags.map((tag) => ({
|
||||
beneficiaryId: existing.id,
|
||||
tagId: tag.id,
|
||||
assignedById: actor.userId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'BENEFICIARY_PROFILE_UPDATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'BENEFICIARY',
|
||||
resourcePublicId: existing.publicId,
|
||||
metadata: {
|
||||
status: input.status,
|
||||
keyCareFlag: input.keyCareFlag,
|
||||
isMinor: input.isMinor,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
return transaction.beneficiaryProfile.findUniqueOrThrow({
|
||||
where: { id: existing.id },
|
||||
include: beneficiaryInclude,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
listTags() {
|
||||
return this.prisma.tag.findMany({ orderBy: { code: 'asc' } });
|
||||
}
|
||||
|
||||
findOwnerCandidate(publicId: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: { publicId, status: 'ACTIVE', staffProfile: { status: 'ACTIVE' } },
|
||||
include: { staffProfile: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findCollaboratorCandidates(publicIds: string[]) {
|
||||
return this.prisma.user.findMany({
|
||||
where: {
|
||||
publicId: { in: publicIds },
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{ staffProfile: { status: 'ACTIVE' } },
|
||||
{ userRoles: { some: { role: { code: 'VOLUNTEER' } } } },
|
||||
],
|
||||
},
|
||||
include: { staffProfile: true, userRoles: { include: { role: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async listAssigneeOptions() {
|
||||
return this.prisma.user.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{ staffProfile: { status: 'ACTIVE' } },
|
||||
{ userRoles: { some: { role: { code: 'VOLUNTEER' } } } },
|
||||
],
|
||||
},
|
||||
include: { staffProfile: true, userRoles: { include: { role: true } } },
|
||||
orderBy: [{ createdAt: 'asc' }],
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
async transitionApplication(
|
||||
publicId: string,
|
||||
expectedStatus: 'SUBMITTED' | 'PENDING_CONTACT',
|
||||
targetStatus: 'PENDING_CONTACT' | 'ASSESSING',
|
||||
reasonCode: string,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<boolean> {
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const application = await transaction.serviceApplication.findUnique({ where: { publicId } });
|
||||
if (!application) return false;
|
||||
const updated = await transaction.serviceApplication.updateMany({
|
||||
where: { id: application.id, status: expectedStatus },
|
||||
data: { status: targetStatus },
|
||||
});
|
||||
if (updated.count !== 1) return false;
|
||||
await transaction.serviceApplicationStatusHistory.create({
|
||||
data: {
|
||||
applicationId: application.id,
|
||||
fromStatus: expectedStatus,
|
||||
toStatus: targetStatus,
|
||||
operatorUserId: actor.userId,
|
||||
reasonCode,
|
||||
},
|
||||
});
|
||||
if (targetStatus === 'PENDING_CONTACT')
|
||||
await this.notifications.create(transaction, {
|
||||
userId: application.applicantUserId,
|
||||
eventKey: `application:${publicId}:accepted`,
|
||||
type: 'APPLICATION_ACCEPTED',
|
||||
title: '服务申请已受理',
|
||||
body: '工作人员将与您联系并进一步了解需求。',
|
||||
resourceType: 'SERVICE_APPLICATION',
|
||||
resourcePublicId: publicId,
|
||||
});
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'SERVICE_APPLICATION_STATUS_CHANGED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'SERVICE_APPLICATION',
|
||||
resourcePublicId: publicId,
|
||||
metadata: { fromStatus: expectedStatus, toStatus: targetStatus, reasonCode },
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async acceptApplication(
|
||||
input: AcceptApplicationInput,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<StoredServiceCase> {
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const application = await transaction.serviceApplication.findUniqueOrThrow({
|
||||
where: { publicId: input.applicationPublicId },
|
||||
});
|
||||
const owner = await transaction.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: input.ownerStaffId,
|
||||
status: 'ACTIVE',
|
||||
staffProfile: { status: 'ACTIVE' },
|
||||
},
|
||||
});
|
||||
const collaboratorIds = input.collaborators.map((collaborator) => collaborator.userId);
|
||||
const activeCollaboratorCount = await transaction.user.count({
|
||||
where: {
|
||||
id: { in: collaboratorIds },
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{ staffProfile: { status: 'ACTIVE' } },
|
||||
{ userRoles: { some: { role: { code: 'VOLUNTEER' } } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (activeCollaboratorCount !== new Set(collaboratorIds).size) {
|
||||
throw new Error('ASSIGNEE_STATE_CONFLICT');
|
||||
}
|
||||
let beneficiaryId = input.existingBeneficiaryId;
|
||||
if (!beneficiaryId && input.newBeneficiary) {
|
||||
const prepared = input.newBeneficiary;
|
||||
const tags = await transaction.tag.findMany({ where: { code: { in: prepared.tagCodes } } });
|
||||
if (tags.length !== new Set(prepared.tagCodes).size)
|
||||
throw new Error('Unknown beneficiary tag');
|
||||
const beneficiary = await transaction.beneficiaryProfile.create({
|
||||
data: {
|
||||
publicId: prepared.publicId,
|
||||
displayNameEncrypted: prepared.displayNameEncrypted,
|
||||
displayNameHash: prepared.displayNameHash,
|
||||
gender: prepared.gender,
|
||||
birthYear: prepared.birthYear,
|
||||
mobileEncrypted: prepared.mobileEncrypted,
|
||||
mobileHash: prepared.mobileHash,
|
||||
regionCode: prepared.regionCode,
|
||||
addressDetailEncrypted: prepared.addressDetailEncrypted,
|
||||
primaryCategory: prepared.primaryCategory,
|
||||
keyCareFlag: prepared.keyCareFlag,
|
||||
isMinor: prepared.isMinor,
|
||||
notesEncrypted: prepared.notesEncrypted,
|
||||
createdById: actor.userId,
|
||||
updatedById: actor.userId,
|
||||
},
|
||||
});
|
||||
beneficiaryId = beneficiary.id;
|
||||
for (const contact of prepared.contacts) {
|
||||
let consentRecordId: bigint | undefined;
|
||||
if (prepared.isMinor && contact.isGuardian && prepared.guardianConsentConfirmed) {
|
||||
const consent = await transaction.consent.create({
|
||||
data: {
|
||||
publicId: crypto.randomUUID(),
|
||||
beneficiaryId,
|
||||
consentType: 'BENEFICIARY_GUARDIAN',
|
||||
version: 'beneficiary-guardian-v1',
|
||||
grantedBy: 'GUARDIAN',
|
||||
grantedAt: new Date(),
|
||||
evidenceJson: {
|
||||
source: 'ADMIN_STAFF_RECORDED',
|
||||
relatedApplicationPublicId: input.applicationPublicId,
|
||||
},
|
||||
},
|
||||
});
|
||||
consentRecordId = consent.id;
|
||||
}
|
||||
await transaction.beneficiaryContact.create({
|
||||
data: { ...contact, beneficiaryId, consentRecordId },
|
||||
});
|
||||
}
|
||||
if (tags.length) {
|
||||
await transaction.beneficiaryTag.createMany({
|
||||
data: tags.map((tag) => ({
|
||||
beneficiaryId: beneficiary.id,
|
||||
tagId: tag.id,
|
||||
assignedById: actor.userId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!beneficiaryId) throw new Error('Beneficiary resolution failed');
|
||||
const accepted = await transaction.serviceApplication.updateMany({
|
||||
where: { id: application.id, status: 'ASSESSING' },
|
||||
data: { status: 'ACCEPTED', beneficiaryId, acceptedAt: new Date() },
|
||||
});
|
||||
if (accepted.count !== 1) throw new Error('APPLICATION_STATE_CONFLICT');
|
||||
await transaction.serviceApplicationStatusHistory.create({
|
||||
data: {
|
||||
applicationId: application.id,
|
||||
fromStatus: 'ASSESSING',
|
||||
toStatus: 'ACCEPTED',
|
||||
operatorUserId: actor.userId,
|
||||
reasonCode: 'ASSESSMENT_ACCEPTED',
|
||||
},
|
||||
});
|
||||
const serviceCase = await transaction.serviceCase.create({
|
||||
data: {
|
||||
publicId: input.casePublicId,
|
||||
caseNo: input.caseNo,
|
||||
applicationId: application.id,
|
||||
acceptRequestId: input.acceptRequestId,
|
||||
acceptRequestFingerprint: input.acceptRequestFingerprint,
|
||||
beneficiaryId,
|
||||
ownerStaffId: owner.id,
|
||||
category: application.serviceCategory,
|
||||
priority: input.priority,
|
||||
plannedStartAt: input.plannedStartAt,
|
||||
plannedEndAt: input.plannedEndAt,
|
||||
serviceLocationEncrypted: input.serviceLocationEncrypted,
|
||||
summaryEncrypted: input.summaryEncrypted,
|
||||
createdById: actor.userId,
|
||||
updatedById: actor.userId,
|
||||
},
|
||||
});
|
||||
await transaction.caseAssignee.create({
|
||||
data: {
|
||||
caseId: serviceCase.id,
|
||||
userId: owner.id,
|
||||
assigneeType: 'STAFF',
|
||||
role: 'OWNER',
|
||||
assignedById: actor.userId,
|
||||
},
|
||||
});
|
||||
for (const collaborator of input.collaborators) {
|
||||
if (collaborator.userId === owner.id) continue;
|
||||
await transaction.caseAssignee.create({
|
||||
data: {
|
||||
caseId: serviceCase.id,
|
||||
userId: collaborator.userId,
|
||||
assigneeType: collaborator.type,
|
||||
role: 'COLLABORATOR',
|
||||
assignedById: actor.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
await transaction.serviceCaseStatusHistory.create({
|
||||
data: {
|
||||
caseId: serviceCase.id,
|
||||
fromStatus: null,
|
||||
toStatus: 'ASSIGNED',
|
||||
operatorUserId: actor.userId,
|
||||
reasonCode: 'CASE_CREATED_AND_ASSIGNED',
|
||||
},
|
||||
});
|
||||
await transaction.serviceApplication.update({
|
||||
where: { id: application.id },
|
||||
data: { status: 'SERVICE_PLANNED' },
|
||||
});
|
||||
await transaction.serviceApplicationStatusHistory.create({
|
||||
data: {
|
||||
applicationId: application.id,
|
||||
fromStatus: 'ACCEPTED',
|
||||
toStatus: 'SERVICE_PLANNED',
|
||||
operatorUserId: actor.userId,
|
||||
reasonCode: 'CASE_CREATED_AND_ASSIGNED',
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'SERVICE_APPLICATION_ACCEPTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'SERVICE_APPLICATION',
|
||||
resourcePublicId: input.applicationPublicId,
|
||||
metadata: {
|
||||
casePublicId: input.casePublicId,
|
||||
beneficiaryPublicId: input.newBeneficiary?.publicId ?? null,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'SERVICE_CASE_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'SERVICE_CASE',
|
||||
resourcePublicId: input.casePublicId,
|
||||
metadata: { priority: input.priority },
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
return transaction.serviceCase.findUniqueOrThrow({
|
||||
where: { id: serviceCase.id },
|
||||
include: caseInclude,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
findCase(publicId: string): Promise<StoredServiceCase | null> {
|
||||
return this.prisma.serviceCase.findUnique({ where: { publicId }, include: caseInclude });
|
||||
}
|
||||
|
||||
findCaseById(id: bigint): Promise<StoredServiceCase | null> {
|
||||
return this.prisma.serviceCase.findUnique({ where: { id }, include: caseInclude });
|
||||
}
|
||||
|
||||
async listCases(
|
||||
filters: CaseFilters,
|
||||
actorUserId: bigint,
|
||||
accessAll: boolean,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
) {
|
||||
const where: Prisma.ServiceCaseWhereInput = {
|
||||
...(accessAll ? {} : { assignees: { some: { userId: actorUserId, removedAt: null } } }),
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.category ? { category: filters.category } : {}),
|
||||
...(filters.caseNo ? { caseNo: { startsWith: filters.caseNo } } : {}),
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.serviceCase.findMany({
|
||||
where,
|
||||
include: caseInclude,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.serviceCase.count({ where }),
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
isAssigned(caseId: bigint, userId: bigint) {
|
||||
return this.prisma.caseAssignee.count({ where: { caseId, userId, removedAt: null } });
|
||||
}
|
||||
|
||||
async startCase(
|
||||
serviceCase: StoredServiceCase,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<StoredServiceCase | null> {
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const updated = await transaction.serviceCase.updateMany({
|
||||
where: { id: serviceCase.id, status: 'ASSIGNED', ownerStaffId: actor.userId },
|
||||
data: { status: 'IN_SERVICE', updatedById: actor.userId },
|
||||
});
|
||||
if (updated.count !== 1) return null;
|
||||
await transaction.serviceCaseStatusHistory.create({
|
||||
data: {
|
||||
caseId: serviceCase.id,
|
||||
fromStatus: 'ASSIGNED',
|
||||
toStatus: 'IN_SERVICE',
|
||||
operatorUserId: actor.userId,
|
||||
reasonCode: 'SERVICE_STARTED',
|
||||
},
|
||||
});
|
||||
const applicationUpdated = await transaction.serviceApplication.updateMany({
|
||||
where: { id: serviceCase.applicationId, status: 'SERVICE_PLANNED' },
|
||||
data: { status: 'IN_SERVICE' },
|
||||
});
|
||||
if (applicationUpdated.count !== 1) throw new Error('APPLICATION_STATE_CONFLICT');
|
||||
await transaction.serviceApplicationStatusHistory.create({
|
||||
data: {
|
||||
applicationId: serviceCase.applicationId,
|
||||
fromStatus: 'SERVICE_PLANNED',
|
||||
toStatus: 'IN_SERVICE',
|
||||
operatorUserId: actor.userId,
|
||||
reasonCode: 'SERVICE_STARTED',
|
||||
},
|
||||
});
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'SERVICE_CASE_STATUS_CHANGED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'SERVICE_CASE',
|
||||
resourcePublicId: serviceCase.publicId,
|
||||
metadata: { fromStatus: 'ASSIGNED', toStatus: 'IN_SERVICE' },
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
return transaction.serviceCase.findUniqueOrThrow({
|
||||
where: { id: serviceCase.id },
|
||||
include: caseInclude,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async replaceCollaborators(
|
||||
serviceCase: StoredServiceCase,
|
||||
collaborators: Array<{ userId: bigint; type: 'STAFF' | 'VOLUNTEER' }>,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<StoredServiceCase> {
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
await transaction.caseAssignee.updateMany({
|
||||
where: { caseId: serviceCase.id, role: 'COLLABORATOR', removedAt: null },
|
||||
data: { removedAt: new Date() },
|
||||
});
|
||||
for (const collaborator of collaborators) {
|
||||
if (collaborator.userId === serviceCase.ownerStaffId) continue;
|
||||
await transaction.caseAssignee.upsert({
|
||||
where: { caseId_userId: { caseId: serviceCase.id, userId: collaborator.userId } },
|
||||
create: {
|
||||
caseId: serviceCase.id,
|
||||
userId: collaborator.userId,
|
||||
assigneeType: collaborator.type,
|
||||
role: 'COLLABORATOR',
|
||||
assignedById: actor.userId,
|
||||
},
|
||||
update: {
|
||||
assigneeType: collaborator.type,
|
||||
role: 'COLLABORATOR',
|
||||
assignedById: actor.userId,
|
||||
assignedAt: new Date(),
|
||||
removedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'SERVICE_CASE_ASSIGNEES_REPLACED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'SERVICE_CASE',
|
||||
resourcePublicId: serviceCase.publicId,
|
||||
metadata: { collaboratorCount: collaborators.length },
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
return transaction.serviceCase.findUniqueOrThrow({
|
||||
where: { id: serviceCase.id },
|
||||
include: caseInclude,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
findRecordByIdempotency(createdById: bigint, clientRequestId: string) {
|
||||
return this.prisma.serviceRecord.findUnique({
|
||||
where: { createdById_clientRequestId: { createdById, clientRequestId } },
|
||||
include: {
|
||||
creator: { include: { staffProfile: true } },
|
||||
attachments: { include: { file: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findRecord(publicId: string) {
|
||||
return this.prisma.serviceRecord.findUnique({
|
||||
where: { publicId },
|
||||
include: {
|
||||
creator: { include: { staffProfile: true } },
|
||||
attachments: { include: { file: true } },
|
||||
serviceCase: { include: caseInclude },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createRecord(
|
||||
serviceCase: StoredServiceCase,
|
||||
input: PreparedServiceRecord,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
) {
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const assigned = await transaction.caseAssignee.count({
|
||||
where: { caseId: serviceCase.id, userId: actor.userId, removedAt: null },
|
||||
});
|
||||
if (!assigned || serviceCase.status !== 'IN_SERVICE')
|
||||
throw new Error('CASE_RECORD_FORBIDDEN');
|
||||
const files = input.attachmentPublicIds.length
|
||||
? await transaction.file.findMany({
|
||||
where: {
|
||||
publicId: { in: input.attachmentPublicIds },
|
||||
caseId: serviceCase.id,
|
||||
uploaderUserId: actor.userId,
|
||||
status: 'AVAILABLE',
|
||||
},
|
||||
})
|
||||
: [];
|
||||
if (files.length !== new Set(input.attachmentPublicIds).size) throw new Error('FILE_INVALID');
|
||||
const record = await transaction.serviceRecord.create({
|
||||
data: {
|
||||
publicId: input.publicId,
|
||||
caseId: serviceCase.id,
|
||||
beneficiaryId: serviceCase.beneficiaryId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: input.requestFingerprint,
|
||||
serviceStartAt: input.serviceStartAt,
|
||||
serviceEndAt: input.serviceEndAt,
|
||||
serviceType: input.serviceType,
|
||||
contentEncrypted: input.contentEncrypted,
|
||||
resultEncrypted: input.resultEncrypted,
|
||||
followUpSuggestionEncrypted: input.followUpSuggestionEncrypted,
|
||||
beneficiaryConfirmStatus: input.beneficiaryConfirmStatus,
|
||||
createdById: actor.userId,
|
||||
updatedById: actor.userId,
|
||||
},
|
||||
});
|
||||
if (files.length) {
|
||||
await transaction.serviceRecordFile.createMany({
|
||||
data: files.map((file) => ({ serviceRecordId: record.id, fileId: file.id })),
|
||||
});
|
||||
}
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'SERVICE_RECORD_CREATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'SERVICE_RECORD',
|
||||
resourcePublicId: input.publicId,
|
||||
metadata: { casePublicId: serviceCase.publicId, serviceType: input.serviceType },
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
return transaction.serviceRecord.findUniqueOrThrow({
|
||||
where: { id: record.id },
|
||||
include: {
|
||||
creator: { include: { staffProfile: true } },
|
||||
attachments: { include: { file: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateRecord(
|
||||
record: NonNullable<Awaited<ReturnType<CaseManagementRepository['findRecord']>>>,
|
||||
input: Omit<PreparedServiceRecord, 'publicId' | 'clientRequestId' | 'requestFingerprint'> & {
|
||||
expectedRevision: number;
|
||||
},
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
) {
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const assigned = await transaction.caseAssignee.count({
|
||||
where: { caseId: record.caseId, userId: actor.userId, removedAt: null },
|
||||
});
|
||||
if (!assigned || record.serviceCase.status !== 'IN_SERVICE') {
|
||||
throw new Error('CASE_RECORD_FORBIDDEN');
|
||||
}
|
||||
const files = input.attachmentPublicIds.length
|
||||
? await transaction.file.findMany({
|
||||
where: {
|
||||
publicId: { in: input.attachmentPublicIds },
|
||||
caseId: record.caseId,
|
||||
status: 'AVAILABLE',
|
||||
OR: [
|
||||
{ uploaderUserId: actor.userId },
|
||||
{ serviceRecords: { some: { serviceRecordId: record.id } } },
|
||||
],
|
||||
},
|
||||
})
|
||||
: [];
|
||||
if (files.length !== new Set(input.attachmentPublicIds).size) throw new Error('FILE_INVALID');
|
||||
await transaction.serviceRecordRevision.create({
|
||||
data: {
|
||||
serviceRecordId: record.id,
|
||||
revision: record.revision,
|
||||
serviceStartAt: record.serviceStartAt,
|
||||
serviceEndAt: record.serviceEndAt,
|
||||
serviceType: record.serviceType,
|
||||
contentEncrypted: record.contentEncrypted,
|
||||
resultEncrypted: record.resultEncrypted,
|
||||
followUpSuggestionEncrypted: record.followUpSuggestionEncrypted,
|
||||
beneficiaryConfirmStatus: record.beneficiaryConfirmStatus,
|
||||
revisedById: actor.userId,
|
||||
},
|
||||
});
|
||||
const updated = await transaction.serviceRecord.updateMany({
|
||||
where: { id: record.id, revision: input.expectedRevision },
|
||||
data: {
|
||||
serviceStartAt: input.serviceStartAt,
|
||||
serviceEndAt: input.serviceEndAt,
|
||||
serviceType: input.serviceType,
|
||||
contentEncrypted: input.contentEncrypted,
|
||||
resultEncrypted: input.resultEncrypted,
|
||||
followUpSuggestionEncrypted: input.followUpSuggestionEncrypted,
|
||||
beneficiaryConfirmStatus: input.beneficiaryConfirmStatus,
|
||||
updatedById: actor.userId,
|
||||
revision: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) throw new Error('RECORD_REVISION_CONFLICT');
|
||||
await transaction.serviceRecordFile.deleteMany({ where: { serviceRecordId: record.id } });
|
||||
if (files.length) {
|
||||
await transaction.serviceRecordFile.createMany({
|
||||
data: files.map((file) => ({ serviceRecordId: record.id, fileId: file.id })),
|
||||
});
|
||||
}
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'SERVICE_RECORD_UPDATED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'SERVICE_RECORD',
|
||||
resourcePublicId: record.publicId,
|
||||
metadata: { fromRevision: record.revision, toRevision: record.revision + 1 },
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
return transaction.serviceRecord.findUniqueOrThrow({
|
||||
where: { id: record.id },
|
||||
include: {
|
||||
creator: { include: { staffProfile: true } },
|
||||
attachments: { include: { file: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
PERMISSION_CODES,
|
||||
SERVICE_CASE_PRIORITY_LABELS,
|
||||
SERVICE_CASE_STATUS_LABELS,
|
||||
SERVICE_RECORD_TYPE_LABELS,
|
||||
type AcceptServiceApplicationResponse,
|
||||
type AssigneeOption,
|
||||
type BeneficiaryDetail,
|
||||
type BeneficiaryDetailResponse,
|
||||
type BeneficiaryListItem,
|
||||
type BeneficiaryTagOption,
|
||||
type PaginatedBeneficiariesResponse,
|
||||
type PaginatedServiceCasesResponse,
|
||||
type ServiceCaseDetail,
|
||||
type ServiceCaseDetailResponse,
|
||||
type ServiceCaseListItem,
|
||||
type ServiceRecordItem,
|
||||
type ServiceRecordResponse,
|
||||
type UpdateBeneficiaryRequest,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type {
|
||||
AcceptServiceApplicationDto,
|
||||
BeneficiaryQueryDto,
|
||||
CreateServiceRecordDto,
|
||||
ReplaceCaseAssigneesDto,
|
||||
ServiceCaseQueryDto,
|
||||
TransitionServiceApplicationDto,
|
||||
UpdateBeneficiaryDto,
|
||||
UpdateServiceRecordDto,
|
||||
} from './dto/case-management.dto';
|
||||
import {
|
||||
CaseManagementRepository,
|
||||
type PreparedBeneficiary,
|
||||
type StoredBeneficiary,
|
||||
type StoredServiceCase,
|
||||
} from './case-management.repository';
|
||||
|
||||
function maskMobile(value: string): string {
|
||||
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
|
||||
}
|
||||
|
||||
function shanghaiDatePrefix(date: Date): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(date);
|
||||
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${value.year}${value.month}${value.day}`;
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown): error is { code: 'P2002' } {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CaseManagementService {
|
||||
constructor(
|
||||
@Inject(CaseManagementRepository) private readonly repository: CaseManagementRepository,
|
||||
@Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
tags(): Promise<BeneficiaryTagOption[]> {
|
||||
return this.repository
|
||||
.listTags()
|
||||
.then((tags) =>
|
||||
tags.map((tag) => ({ code: tag.code, name: tag.name, description: tag.description })),
|
||||
);
|
||||
}
|
||||
|
||||
async transitionApplication(
|
||||
publicId: string,
|
||||
input: TransitionServiceApplicationDto,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<{ status: string }> {
|
||||
const expected = input.targetStatus === 'PENDING_CONTACT' ? 'SUBMITTED' : 'PENDING_CONTACT';
|
||||
const application = await this.repository.findApplication(publicId);
|
||||
if (!application)
|
||||
throw this.notFound(API_ERROR_CODES.APPLICATION_NOT_FOUND, '未找到该服务申请');
|
||||
if (application.status !== expected) throw this.invalidTransition();
|
||||
const changed = await this.repository.transitionApplication(
|
||||
publicId,
|
||||
expected,
|
||||
input.targetStatus,
|
||||
input.reasonCode,
|
||||
actor,
|
||||
request,
|
||||
);
|
||||
if (!changed) throw this.invalidTransition();
|
||||
return { status: input.targetStatus };
|
||||
}
|
||||
|
||||
async acceptApplication(
|
||||
publicId: string,
|
||||
input: AcceptServiceApplicationDto,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<AcceptServiceApplicationResponse> {
|
||||
const normalized = this.normalizeAcceptance(input);
|
||||
const fingerprint = this.sensitiveData.lookupHash(
|
||||
'service-application-acceptance',
|
||||
JSON.stringify(normalized),
|
||||
);
|
||||
const application = await this.repository.findApplication(publicId);
|
||||
if (!application)
|
||||
throw this.notFound(API_ERROR_CODES.APPLICATION_NOT_FOUND, '未找到该服务申请');
|
||||
if (application.serviceCase) {
|
||||
if (
|
||||
application.serviceCase.acceptRequestId !== input.clientRequestId ||
|
||||
application.serviceCase.acceptRequestFingerprint !== fingerprint
|
||||
) {
|
||||
throw this.duplicateRequest();
|
||||
}
|
||||
return { serviceCase: this.toCaseDetail(application.serviceCase), idempotentReplay: true };
|
||||
}
|
||||
if (application.status !== 'ASSESSING') throw this.invalidTransition();
|
||||
const owner = await this.repository.findOwnerCandidate(input.ownerStaffPublicId);
|
||||
if (!owner) throw this.validation('负责人必须是有效工作人员');
|
||||
const collaboratorIds = [...new Set(input.collaboratorUserPublicIds)];
|
||||
if (collaboratorIds.length !== input.collaboratorUserPublicIds.length) {
|
||||
throw this.validation('协作人员不能重复');
|
||||
}
|
||||
const collaboratorUsers = await this.repository.findCollaboratorCandidates(collaboratorIds);
|
||||
if (collaboratorUsers.length !== collaboratorIds.length) {
|
||||
throw this.validation('存在无效或未获授权的协作人员');
|
||||
}
|
||||
let existingBeneficiaryId: bigint | undefined;
|
||||
let newBeneficiary: PreparedBeneficiary | undefined;
|
||||
if (input.beneficiary.mode === 'LINK_EXISTING') {
|
||||
if (
|
||||
!input.beneficiary.beneficiaryPublicId ||
|
||||
input.beneficiary.manualMatchConfirmed !== true
|
||||
) {
|
||||
throw this.validation('关联已有档案必须完成人工核验');
|
||||
}
|
||||
const beneficiary = await this.repository.findBeneficiary(
|
||||
input.beneficiary.beneficiaryPublicId,
|
||||
);
|
||||
if (!beneficiary) {
|
||||
throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案');
|
||||
}
|
||||
if (!beneficiary.mobileHash || beneficiary.mobileHash !== application.contactMobileHash) {
|
||||
throw this.validation('手机号不一致,不能关联已有档案');
|
||||
}
|
||||
existingBeneficiaryId = beneficiary.id;
|
||||
} else {
|
||||
if (!input.beneficiary.profile) throw this.validation('请填写服务对象档案');
|
||||
const profile = input.beneficiary.profile;
|
||||
const guardians = profile.contacts.filter((contact) => contact.isGuardian);
|
||||
if (profile.isMinor && (!guardians.length || profile.guardianConsentConfirmed !== true)) {
|
||||
throw this.validation('未成年人档案必须记录监护人及其明确授权');
|
||||
}
|
||||
newBeneficiary = this.prepareBeneficiary(profile, application.serviceCategory);
|
||||
}
|
||||
const plannedStartAt = normalized.plannedStartAt
|
||||
? new Date(normalized.plannedStartAt)
|
||||
: undefined;
|
||||
const plannedEndAt = normalized.plannedEndAt ? new Date(normalized.plannedEndAt) : undefined;
|
||||
if (plannedStartAt && plannedEndAt && plannedEndAt < plannedStartAt) {
|
||||
throw this.validation('计划结束时间不能早于开始时间');
|
||||
}
|
||||
try {
|
||||
const created = await this.repository.acceptApplication(
|
||||
{
|
||||
applicationPublicId: publicId,
|
||||
acceptRequestId: input.clientRequestId,
|
||||
acceptRequestFingerprint: fingerprint,
|
||||
existingBeneficiaryId,
|
||||
newBeneficiary,
|
||||
ownerStaffId: owner.id,
|
||||
collaborators: collaboratorUsers.map((user) => ({
|
||||
userId: user.id,
|
||||
type: user.staffProfile ? 'STAFF' : 'VOLUNTEER',
|
||||
})),
|
||||
casePublicId: randomUUID(),
|
||||
caseNo: `WO${shanghaiDatePrefix(new Date())}${randomBytes(5).toString('hex').toUpperCase()}`,
|
||||
priority: input.priority,
|
||||
plannedStartAt,
|
||||
plannedEndAt,
|
||||
serviceLocationEncrypted: normalized.serviceLocation
|
||||
? this.sensitiveData.encrypt(normalized.serviceLocation)
|
||||
: undefined,
|
||||
summaryEncrypted: this.sensitiveData.encrypt(normalized.summary),
|
||||
},
|
||||
actor,
|
||||
request,
|
||||
);
|
||||
return { serviceCase: this.toCaseDetail(created), idempotentReplay: false };
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
const replay = await this.repository.findApplication(publicId);
|
||||
if (
|
||||
replay?.serviceCase?.acceptRequestId === input.clientRequestId &&
|
||||
replay.serviceCase.acceptRequestFingerprint === fingerprint
|
||||
) {
|
||||
return { serviceCase: this.toCaseDetail(replay.serviceCase), idempotentReplay: true };
|
||||
}
|
||||
throw this.duplicateRequest();
|
||||
}
|
||||
if (error instanceof Error && error.message === 'APPLICATION_STATE_CONFLICT') {
|
||||
throw this.invalidTransition();
|
||||
}
|
||||
if (error instanceof Error && error.message === 'ASSIGNEE_STATE_CONFLICT') {
|
||||
throw this.validation('协作人员状态已变化,请刷新后重试');
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Unknown beneficiary tag') {
|
||||
throw this.validation('包含未知的服务标签');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async listBeneficiaries(query: BeneficiaryQueryDto): Promise<PaginatedBeneficiariesResponse> {
|
||||
const result = await this.repository.listBeneficiaries(
|
||||
{
|
||||
...(query.primaryCategory ? { primaryCategory: query.primaryCategory } : {}),
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.keyCareFlag === undefined ? {} : { keyCareFlag: query.keyCareFlag }),
|
||||
},
|
||||
query.page,
|
||||
query.pageSize,
|
||||
);
|
||||
return {
|
||||
items: result.items.map((item) => this.toBeneficiaryListItem(item)),
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
async getBeneficiary(
|
||||
publicId: string,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<BeneficiaryDetailResponse> {
|
||||
const beneficiary = await this.repository.findBeneficiary(publicId);
|
||||
if (!beneficiary) {
|
||||
throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案');
|
||||
}
|
||||
await this.audit.record(request, {
|
||||
action: 'BENEFICIARY_DETAIL_VIEWED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'BENEFICIARY',
|
||||
resourcePublicId: publicId,
|
||||
});
|
||||
return { beneficiary: this.toBeneficiaryDetail(beneficiary) };
|
||||
}
|
||||
|
||||
async updateBeneficiary(
|
||||
publicId: string,
|
||||
input: UpdateBeneficiaryDto,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<BeneficiaryDetailResponse> {
|
||||
const existing = await this.repository.findBeneficiary(publicId);
|
||||
if (!existing) {
|
||||
throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案');
|
||||
}
|
||||
const guardians = input.contacts.filter((contact) => contact.isGuardian);
|
||||
if (input.isMinor && (!guardians.length || input.guardianConsentConfirmed !== true)) {
|
||||
throw this.validation('未成年人档案必须记录监护人及其明确授权');
|
||||
}
|
||||
try {
|
||||
const prepared = this.prepareBeneficiary(input, existing.primaryCategory);
|
||||
const updated = await this.repository.updateBeneficiary(
|
||||
{ ...existing },
|
||||
{ ...prepared, status: input.status },
|
||||
actor,
|
||||
request,
|
||||
);
|
||||
return { beneficiary: this.toBeneficiaryDetail(updated) };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Unknown beneficiary tag') {
|
||||
throw this.validation('包含未知的服务标签');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async assigneeOptions(): Promise<AssigneeOption[]> {
|
||||
const users = await this.repository.listAssigneeOptions();
|
||||
return users.map((user) => ({
|
||||
userPublicId: user.publicId,
|
||||
displayName: user.staffProfile?.displayName ?? user.nickname ?? '志愿者',
|
||||
type: user.staffProfile ? 'STAFF' : 'VOLUNTEER',
|
||||
department: user.staffProfile?.department ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async listCases(
|
||||
query: ServiceCaseQueryDto,
|
||||
actor: AuthContext,
|
||||
): Promise<PaginatedServiceCasesResponse> {
|
||||
const result = await this.repository.listCases(
|
||||
{
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.category ? { category: query.category } : {}),
|
||||
...(query.caseNo?.trim() ? { caseNo: query.caseNo.trim().toUpperCase() } : {}),
|
||||
},
|
||||
actor.userId,
|
||||
actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL),
|
||||
query.page,
|
||||
query.pageSize,
|
||||
);
|
||||
return {
|
||||
items: result.items.map((item) => this.toCaseListItem(item)),
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
async getCase(
|
||||
publicId: string,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
minimal = false,
|
||||
): Promise<ServiceCaseDetailResponse> {
|
||||
const serviceCase = await this.requireAccessibleCase(publicId, actor);
|
||||
await this.audit.record(request, {
|
||||
action: minimal ? 'ASSIGNED_CASE_DETAIL_VIEWED' : 'SERVICE_CASE_DETAIL_VIEWED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'SERVICE_CASE',
|
||||
resourcePublicId: publicId,
|
||||
});
|
||||
return { serviceCase: this.toCaseDetail(serviceCase, minimal) };
|
||||
}
|
||||
|
||||
async startCase(
|
||||
publicId: string,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<ServiceCaseDetailResponse> {
|
||||
const serviceCase = await this.requireAccessibleCase(publicId, actor);
|
||||
if (serviceCase.status !== 'ASSIGNED' || serviceCase.ownerStaffId !== actor.userId) {
|
||||
throw this.invalidTransition();
|
||||
}
|
||||
try {
|
||||
const updated = await this.repository.startCase(serviceCase, actor, request);
|
||||
if (!updated) throw this.invalidTransition();
|
||||
return { serviceCase: this.toCaseDetail(updated) };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'APPLICATION_STATE_CONFLICT') {
|
||||
throw this.invalidTransition();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async replaceAssignees(
|
||||
publicId: string,
|
||||
input: ReplaceCaseAssigneesDto,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<ServiceCaseDetailResponse> {
|
||||
const serviceCase = await this.requireAccessibleCase(publicId, actor);
|
||||
const publicIds = [...new Set(input.collaboratorUserPublicIds)];
|
||||
if (publicIds.length !== input.collaboratorUserPublicIds.length) {
|
||||
throw this.validation('协作人员不能重复');
|
||||
}
|
||||
const users = await this.repository.findCollaboratorCandidates(publicIds);
|
||||
if (users.length !== publicIds.length) throw this.validation('存在无效或未获授权的协作人员');
|
||||
const updated = await this.repository.replaceCollaborators(
|
||||
serviceCase,
|
||||
users.map((user) => ({ userId: user.id, type: user.staffProfile ? 'STAFF' : 'VOLUNTEER' })),
|
||||
actor,
|
||||
request,
|
||||
);
|
||||
return { serviceCase: this.toCaseDetail(updated) };
|
||||
}
|
||||
|
||||
async createRecord(
|
||||
casePublicId: string,
|
||||
input: CreateServiceRecordDto,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<ServiceRecordResponse> {
|
||||
const serviceCase = await this.requireAssignedCase(casePublicId, actor);
|
||||
const normalized = {
|
||||
serviceStartAt: input.serviceStartAt,
|
||||
serviceEndAt: input.serviceEndAt ?? null,
|
||||
serviceType: input.serviceType,
|
||||
content: input.content.trim(),
|
||||
result: input.result.trim(),
|
||||
followUpSuggestion: input.followUpSuggestion?.trim() || null,
|
||||
beneficiaryConfirmStatus: input.beneficiaryConfirmStatus,
|
||||
attachmentPublicIds: [...new Set(input.attachmentPublicIds)].sort(),
|
||||
};
|
||||
const fingerprint = this.sensitiveData.lookupHash(
|
||||
'service-record-request',
|
||||
JSON.stringify(normalized),
|
||||
);
|
||||
const previous = await this.repository.findRecordByIdempotency(
|
||||
actor.userId,
|
||||
input.clientRequestId,
|
||||
);
|
||||
if (previous) {
|
||||
if (previous.requestFingerprint !== fingerprint) throw this.duplicateRequest();
|
||||
return { record: this.toServiceRecord(previous), idempotentReplay: true };
|
||||
}
|
||||
const start = new Date(input.serviceStartAt);
|
||||
const end = input.serviceEndAt ? new Date(input.serviceEndAt) : undefined;
|
||||
if (end && end < start) throw this.validation('服务结束时间不能早于开始时间');
|
||||
if (serviceCase.status !== 'IN_SERVICE') throw this.invalidTransition();
|
||||
try {
|
||||
const record = await this.repository.createRecord(
|
||||
serviceCase,
|
||||
{
|
||||
publicId: randomUUID(),
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fingerprint,
|
||||
serviceStartAt: start,
|
||||
serviceEndAt: end,
|
||||
serviceType: input.serviceType,
|
||||
contentEncrypted: this.sensitiveData.encrypt(normalized.content),
|
||||
resultEncrypted: this.sensitiveData.encrypt(normalized.result),
|
||||
followUpSuggestionEncrypted: normalized.followUpSuggestion
|
||||
? this.sensitiveData.encrypt(normalized.followUpSuggestion)
|
||||
: undefined,
|
||||
beneficiaryConfirmStatus: input.beneficiaryConfirmStatus,
|
||||
attachmentPublicIds: normalized.attachmentPublicIds,
|
||||
},
|
||||
actor,
|
||||
request,
|
||||
);
|
||||
return { record: this.toServiceRecord(record), idempotentReplay: false };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'FILE_INVALID') {
|
||||
throw this.validation('附件不存在、尚未确认或不属于当前提交人');
|
||||
}
|
||||
if (error instanceof Error && error.message === 'CASE_RECORD_FORBIDDEN') {
|
||||
throw this.invalidTransition();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateRecord(
|
||||
casePublicId: string,
|
||||
recordPublicId: string,
|
||||
input: UpdateServiceRecordDto,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<ServiceRecordResponse> {
|
||||
const serviceCase = await this.requireAssignedCase(casePublicId, actor);
|
||||
const record = await this.repository.findRecord(recordPublicId);
|
||||
if (!record || record.caseId !== serviceCase.id) {
|
||||
throw this.notFound(API_ERROR_CODES.SERVICE_RECORD_NOT_FOUND, '未找到该服务记录');
|
||||
}
|
||||
if (
|
||||
record.createdById !== actor.userId &&
|
||||
serviceCase.ownerStaffId !== actor.userId &&
|
||||
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
|
||||
) {
|
||||
throw this.notFound(API_ERROR_CODES.SERVICE_RECORD_NOT_FOUND, '未找到该服务记录');
|
||||
}
|
||||
const start = new Date(input.serviceStartAt);
|
||||
const end = input.serviceEndAt ? new Date(input.serviceEndAt) : undefined;
|
||||
if (end && end < start) throw this.validation('服务结束时间不能早于开始时间');
|
||||
try {
|
||||
const updated = await this.repository.updateRecord(
|
||||
record,
|
||||
{
|
||||
expectedRevision: input.expectedRevision,
|
||||
serviceStartAt: start,
|
||||
serviceEndAt: end,
|
||||
serviceType: input.serviceType,
|
||||
contentEncrypted: this.sensitiveData.encrypt(input.content.trim()),
|
||||
resultEncrypted: this.sensitiveData.encrypt(input.result.trim()),
|
||||
followUpSuggestionEncrypted: input.followUpSuggestion?.trim()
|
||||
? this.sensitiveData.encrypt(input.followUpSuggestion.trim())
|
||||
: undefined,
|
||||
beneficiaryConfirmStatus: input.beneficiaryConfirmStatus,
|
||||
attachmentPublicIds: [...new Set(input.attachmentPublicIds)],
|
||||
},
|
||||
actor,
|
||||
request,
|
||||
);
|
||||
return { record: this.toServiceRecord(updated) };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'RECORD_REVISION_CONFLICT') {
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.INVALID_STATE_TRANSITION,
|
||||
'服务记录已被更新,请刷新后重试',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
if (error instanceof Error && error.message === 'FILE_INVALID') {
|
||||
throw this.validation('附件不存在、尚未确认或无权关联');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async requireAssignedCase(publicId: string, actor: AuthContext): Promise<StoredServiceCase> {
|
||||
const serviceCase = await this.repository.findCase(publicId);
|
||||
if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
|
||||
const assigned = await this.repository.isAssigned(serviceCase.id, actor.userId);
|
||||
if (!assigned) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
|
||||
return serviceCase;
|
||||
}
|
||||
|
||||
async requireAssignedCaseByIdForFile(
|
||||
caseId: bigint,
|
||||
actor: AuthContext,
|
||||
): Promise<StoredServiceCase> {
|
||||
const serviceCase = await this.repository.findCaseById(caseId);
|
||||
if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
|
||||
if (!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)) {
|
||||
const assigned = await this.repository.isAssigned(caseId, actor.userId);
|
||||
if (!assigned) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
|
||||
}
|
||||
return serviceCase;
|
||||
}
|
||||
|
||||
private async requireAccessibleCase(
|
||||
publicId: string,
|
||||
actor: AuthContext,
|
||||
): Promise<StoredServiceCase> {
|
||||
const serviceCase = await this.repository.findCase(publicId);
|
||||
if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
|
||||
if (!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)) {
|
||||
const assigned = await this.repository.isAssigned(serviceCase.id, actor.userId);
|
||||
if (!assigned) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
|
||||
}
|
||||
return serviceCase;
|
||||
}
|
||||
|
||||
private normalizeAcceptance(input: AcceptServiceApplicationDto) {
|
||||
return {
|
||||
clientRequestId: input.clientRequestId,
|
||||
beneficiary: input.beneficiary,
|
||||
ownerStaffPublicId: input.ownerStaffPublicId,
|
||||
collaboratorUserPublicIds: [...input.collaboratorUserPublicIds].sort(),
|
||||
priority: input.priority,
|
||||
plannedStartAt: input.plannedStartAt ?? null,
|
||||
plannedEndAt: input.plannedEndAt ?? null,
|
||||
serviceLocation: input.serviceLocation?.trim() || null,
|
||||
summary: input.summary.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private prepareBeneficiary(
|
||||
profile:
|
||||
NonNullable<AcceptServiceApplicationDto['beneficiary']['profile']> | UpdateBeneficiaryRequest,
|
||||
primaryCategory: PreparedBeneficiary['primaryCategory'],
|
||||
): PreparedBeneficiary {
|
||||
const displayName = profile.displayName.trim();
|
||||
const mobile = profile.mobile?.trim();
|
||||
return {
|
||||
publicId: randomUUID(),
|
||||
displayNameEncrypted: this.sensitiveData.encrypt(displayName),
|
||||
displayNameHash: this.sensitiveData.lookupHash('beneficiary-display-name', displayName),
|
||||
gender: profile.gender,
|
||||
birthYear: profile.birthYear,
|
||||
mobileEncrypted: mobile ? this.sensitiveData.encrypt(mobile) : undefined,
|
||||
mobileHash: mobile ? this.sensitiveData.lookupHash('beneficiary-mobile', mobile) : undefined,
|
||||
regionCode: profile.regionCode.trim(),
|
||||
addressDetailEncrypted: profile.addressDetail?.trim()
|
||||
? this.sensitiveData.encrypt(profile.addressDetail.trim())
|
||||
: undefined,
|
||||
primaryCategory,
|
||||
keyCareFlag: profile.keyCareFlag,
|
||||
isMinor: profile.isMinor,
|
||||
notesEncrypted: profile.notes?.trim()
|
||||
? this.sensitiveData.encrypt(profile.notes.trim())
|
||||
: undefined,
|
||||
contacts: profile.contacts.map((contact) => ({
|
||||
publicId: randomUUID(),
|
||||
nameEncrypted: this.sensitiveData.encrypt(contact.name.trim()),
|
||||
relation: contact.relation.trim(),
|
||||
mobileEncrypted: this.sensitiveData.encrypt(contact.mobile.trim()),
|
||||
mobileHash: this.sensitiveData.lookupHash(
|
||||
'beneficiary-contact-mobile',
|
||||
contact.mobile.trim(),
|
||||
),
|
||||
isGuardian: contact.isGuardian,
|
||||
isEmergencyContact: contact.isEmergencyContact,
|
||||
})),
|
||||
tagCodes: [...new Set(profile.tagCodes)],
|
||||
guardianConsentConfirmed: profile.guardianConsentConfirmed === true,
|
||||
};
|
||||
}
|
||||
|
||||
private toBeneficiaryListItem(beneficiary: StoredBeneficiary): BeneficiaryListItem {
|
||||
const mobile = beneficiary.mobileEncrypted
|
||||
? this.sensitiveData.decrypt(beneficiary.mobileEncrypted)
|
||||
: null;
|
||||
return {
|
||||
publicId: beneficiary.publicId,
|
||||
displayName: this.sensitiveData.decrypt(beneficiary.displayNameEncrypted),
|
||||
gender: beneficiary.gender,
|
||||
birthYear: beneficiary.birthYear,
|
||||
mobileMasked: mobile ? maskMobile(mobile) : null,
|
||||
regionCode: beneficiary.regionCode,
|
||||
primaryCategory: beneficiary.primaryCategory,
|
||||
keyCareFlag: beneficiary.keyCareFlag,
|
||||
isMinor: beneficiary.isMinor,
|
||||
status: beneficiary.status,
|
||||
tags: beneficiary.beneficiaryTags.map(({ tag }) => ({
|
||||
code: tag.code,
|
||||
name: tag.name,
|
||||
description: tag.description,
|
||||
})),
|
||||
createdAt: beneficiary.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toBeneficiaryDetail(beneficiary: StoredBeneficiary): BeneficiaryDetail {
|
||||
const mobile = beneficiary.mobileEncrypted
|
||||
? this.sensitiveData.decrypt(beneficiary.mobileEncrypted)
|
||||
: null;
|
||||
return {
|
||||
...this.toBeneficiaryListItem(beneficiary),
|
||||
mobile,
|
||||
addressDetail: beneficiary.addressDetailEncrypted
|
||||
? this.sensitiveData.decrypt(beneficiary.addressDetailEncrypted)
|
||||
: null,
|
||||
notes: beneficiary.notesEncrypted
|
||||
? this.sensitiveData.decrypt(beneficiary.notesEncrypted)
|
||||
: null,
|
||||
contacts: beneficiary.contacts.map((contact) => {
|
||||
const contactMobile = this.sensitiveData.decrypt(contact.mobileEncrypted);
|
||||
return {
|
||||
publicId: contact.publicId,
|
||||
name: this.sensitiveData.decrypt(contact.nameEncrypted),
|
||||
relation: contact.relation,
|
||||
mobile: contactMobile,
|
||||
mobileMasked: maskMobile(contactMobile),
|
||||
isGuardian: contact.isGuardian,
|
||||
isEmergencyContact: contact.isEmergencyContact,
|
||||
hasConsentRecord: contact.consentRecordId !== null,
|
||||
};
|
||||
}),
|
||||
serviceCaseCount: beneficiary._count.serviceCases,
|
||||
};
|
||||
}
|
||||
|
||||
private toCaseListItem(serviceCase: StoredServiceCase): ServiceCaseListItem {
|
||||
return {
|
||||
publicId: serviceCase.publicId,
|
||||
caseNo: serviceCase.caseNo,
|
||||
applicationPublicId: serviceCase.application.publicId,
|
||||
applicationNo: serviceCase.application.applicationNo,
|
||||
beneficiaryPublicId: serviceCase.beneficiary.publicId,
|
||||
beneficiaryDisplayName: this.sensitiveData.decrypt(
|
||||
serviceCase.beneficiary.displayNameEncrypted,
|
||||
),
|
||||
category: serviceCase.category,
|
||||
priority: serviceCase.priority,
|
||||
priorityLabel: SERVICE_CASE_PRIORITY_LABELS[serviceCase.priority],
|
||||
status: serviceCase.status,
|
||||
statusLabel: SERVICE_CASE_STATUS_LABELS[serviceCase.status],
|
||||
ownerDisplayName: serviceCase.owner.staffProfile?.displayName ?? '工作人员',
|
||||
plannedStartAt: serviceCase.plannedStartAt?.toISOString() ?? null,
|
||||
createdAt: serviceCase.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toCaseDetail(serviceCase: StoredServiceCase, minimal = false): ServiceCaseDetail {
|
||||
const mobile = serviceCase.beneficiary.mobileEncrypted
|
||||
? this.sensitiveData.decrypt(serviceCase.beneficiary.mobileEncrypted)
|
||||
: null;
|
||||
return {
|
||||
...this.toCaseListItem(serviceCase),
|
||||
regionCode: serviceCase.beneficiary.regionCode,
|
||||
beneficiaryMobile: minimal ? null : mobile,
|
||||
beneficiaryMobileMasked: mobile ? maskMobile(mobile) : null,
|
||||
serviceLocation: serviceCase.serviceLocationEncrypted
|
||||
? this.sensitiveData.decrypt(serviceCase.serviceLocationEncrypted)
|
||||
: null,
|
||||
summary: this.sensitiveData.decrypt(serviceCase.summaryEncrypted),
|
||||
resultSummary: serviceCase.resultSummaryEncrypted
|
||||
? this.sensitiveData.decrypt(serviceCase.resultSummaryEncrypted)
|
||||
: null,
|
||||
followUpPlan: serviceCase.followUpPlanEncrypted
|
||||
? this.sensitiveData.decrypt(serviceCase.followUpPlanEncrypted)
|
||||
: null,
|
||||
completedAt: serviceCase.completedAt?.toISOString() ?? null,
|
||||
assignees: serviceCase.assignees.map((assignee) => ({
|
||||
userPublicId: assignee.user.publicId,
|
||||
displayName: assignee.user.staffProfile?.displayName ?? assignee.user.nickname ?? '志愿者',
|
||||
type: assignee.assigneeType,
|
||||
department: assignee.user.staffProfile?.department ?? null,
|
||||
role: assignee.role,
|
||||
assignedAt: assignee.assignedAt.toISOString(),
|
||||
})),
|
||||
history: serviceCase.statusHistory.map((history) => ({
|
||||
status: history.toStatus,
|
||||
statusLabel: SERVICE_CASE_STATUS_LABELS[history.toStatus],
|
||||
reasonLabel:
|
||||
history.reasonCode === 'CASE_CREATED_AND_ASSIGNED'
|
||||
? '工单已创建并指派'
|
||||
: history.reasonCode === 'SERVICE_COMPLETED'
|
||||
? '本阶段服务已完成并创建回访'
|
||||
: '服务已开始',
|
||||
createdAt: history.createdAt.toISOString(),
|
||||
})),
|
||||
records: serviceCase.serviceRecords.map((record) => this.toServiceRecord(record)),
|
||||
};
|
||||
}
|
||||
|
||||
private toServiceRecord(record: StoredServiceCase['serviceRecords'][number]): ServiceRecordItem {
|
||||
return {
|
||||
publicId: record.publicId,
|
||||
serviceStartAt: record.serviceStartAt.toISOString(),
|
||||
serviceEndAt: record.serviceEndAt?.toISOString() ?? null,
|
||||
serviceType: record.serviceType,
|
||||
serviceTypeLabel: SERVICE_RECORD_TYPE_LABELS[record.serviceType],
|
||||
content: this.sensitiveData.decrypt(record.contentEncrypted),
|
||||
result: this.sensitiveData.decrypt(record.resultEncrypted),
|
||||
followUpSuggestion: record.followUpSuggestionEncrypted
|
||||
? this.sensitiveData.decrypt(record.followUpSuggestionEncrypted)
|
||||
: null,
|
||||
beneficiaryConfirmStatus: record.beneficiaryConfirmStatus,
|
||||
createdByDisplayName:
|
||||
record.creator.staffProfile?.displayName ?? record.creator.nickname ?? '志愿者',
|
||||
revision: record.revision,
|
||||
attachments: record.attachments.map(({ file }) => ({
|
||||
publicId: file.publicId,
|
||||
filename: this.sensitiveData.decrypt(file.originalFilenameEncrypted),
|
||||
mimeType: file.mimeType,
|
||||
size: Number(file.size),
|
||||
})),
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private invalidTransition(): ApplicationError {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.INVALID_STATE_TRANSITION,
|
||||
'当前状态不能执行此操作,请刷新后重试',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
private duplicateRequest(): ApplicationError {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.DUPLICATE_REQUEST,
|
||||
'本次提交标识已用于其他内容,请重新操作',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
private validation(message: string): ApplicationError {
|
||||
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
private notFound(code: string, message: string): ApplicationError {
|
||||
return new ApplicationError(code as never, message, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import {
|
||||
BENEFICIARY_CONFIRM_STATUSES,
|
||||
BENEFICIARY_GENDERS,
|
||||
BENEFICIARY_STATUSES,
|
||||
SERVICE_CASE_PRIORITIES,
|
||||
SERVICE_CASE_STATUSES,
|
||||
SERVICE_CATEGORIES,
|
||||
SERVICE_RECORD_TYPES,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
const MOBILE_PATTERN = /^1[3-9]\d{9}$/;
|
||||
const TAG_CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/;
|
||||
|
||||
export class PaginationDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize = 20;
|
||||
}
|
||||
|
||||
export class BeneficiaryContactDto {
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
relation!: string;
|
||||
|
||||
@Matches(MOBILE_PATTERN)
|
||||
mobile!: string;
|
||||
|
||||
@IsBoolean()
|
||||
isGuardian!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
isEmergencyContact!: boolean;
|
||||
}
|
||||
|
||||
export class BeneficiaryProfileDto {
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
displayName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(BENEFICIARY_GENDERS)
|
||||
gender?: (typeof BENEFICIARY_GENDERS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1900)
|
||||
@Max(2200)
|
||||
birthYear?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(MOBILE_PATTERN)
|
||||
mobile?: string;
|
||||
|
||||
@IsString()
|
||||
@Length(2, 100)
|
||||
regionCode!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
addressDetail?: string;
|
||||
|
||||
@IsBoolean()
|
||||
keyCareFlag!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
isMinor!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
notes?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BeneficiaryContactDto)
|
||||
contacts!: BeneficiaryContactDto[];
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@Matches(TAG_CODE_PATTERN, { each: true })
|
||||
tagCodes!: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([true])
|
||||
guardianConsentConfirmed?: true;
|
||||
}
|
||||
|
||||
export class BeneficiarySelectionDto {
|
||||
@IsIn(['CREATE', 'LINK_EXISTING'])
|
||||
mode!: 'CREATE' | 'LINK_EXISTING';
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => BeneficiaryProfileDto)
|
||||
profile?: BeneficiaryProfileDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
beneficiaryPublicId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([true])
|
||||
manualMatchConfirmed?: true;
|
||||
}
|
||||
|
||||
export class AcceptServiceApplicationDto {
|
||||
@IsUUID('4')
|
||||
clientRequestId!: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => BeneficiarySelectionDto)
|
||||
beneficiary!: BeneficiarySelectionDto;
|
||||
|
||||
@IsUUID('4')
|
||||
ownerStaffPublicId!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@IsUUID('4', { each: true })
|
||||
collaboratorUserPublicIds!: string[];
|
||||
|
||||
@IsIn(SERVICE_CASE_PRIORITIES)
|
||||
priority!: (typeof SERVICE_CASE_PRIORITIES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
plannedStartAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
plannedEndAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
serviceLocation?: string;
|
||||
|
||||
@IsString()
|
||||
@Length(10, 2000)
|
||||
summary!: string;
|
||||
}
|
||||
|
||||
export class TransitionServiceApplicationDto {
|
||||
@IsIn(['PENDING_CONTACT', 'ASSESSING'])
|
||||
targetStatus!: 'PENDING_CONTACT' | 'ASSESSING';
|
||||
|
||||
@IsString()
|
||||
@Matches(/^[A-Z][A-Z0-9_]{2,99}$/)
|
||||
reasonCode!: string;
|
||||
}
|
||||
|
||||
export class BeneficiaryQueryDto extends PaginationDto {
|
||||
@IsOptional()
|
||||
@IsIn(SERVICE_CATEGORIES)
|
||||
primaryCategory?: (typeof SERVICE_CATEGORIES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(BENEFICIARY_STATUSES)
|
||||
status?: (typeof BENEFICIARY_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
const candidate: unknown = value;
|
||||
if (candidate === 'true' || candidate === true) return true;
|
||||
if (candidate === 'false' || candidate === false) return false;
|
||||
return candidate;
|
||||
})
|
||||
@IsBoolean()
|
||||
keyCareFlag?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateBeneficiaryDto extends BeneficiaryProfileDto {
|
||||
@IsIn(BENEFICIARY_STATUSES)
|
||||
status!: (typeof BENEFICIARY_STATUSES)[number];
|
||||
}
|
||||
|
||||
export class ServiceCaseQueryDto extends PaginationDto {
|
||||
@IsOptional()
|
||||
@IsIn(SERVICE_CASE_STATUSES)
|
||||
status?: (typeof SERVICE_CASE_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(SERVICE_CATEGORIES)
|
||||
category?: (typeof SERVICE_CATEGORIES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
caseNo?: string;
|
||||
}
|
||||
|
||||
export class ReplaceCaseAssigneesDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@IsUUID('4', { each: true })
|
||||
collaboratorUserPublicIds!: string[];
|
||||
}
|
||||
|
||||
export class CreateServiceRecordDto {
|
||||
@IsUUID('4')
|
||||
clientRequestId!: string;
|
||||
|
||||
@IsISO8601({ strict: true })
|
||||
serviceStartAt!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
serviceEndAt?: string;
|
||||
|
||||
@IsIn(SERVICE_RECORD_TYPES)
|
||||
serviceType!: (typeof SERVICE_RECORD_TYPES)[number];
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(5000)
|
||||
content!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(5000)
|
||||
result!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
followUpSuggestion?: string;
|
||||
|
||||
@IsIn(BENEFICIARY_CONFIRM_STATUSES)
|
||||
beneficiaryConfirmStatus!: (typeof BENEFICIARY_CONFIRM_STATUSES)[number];
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(9)
|
||||
@IsUUID('4', { each: true })
|
||||
attachmentPublicIds!: string[];
|
||||
}
|
||||
|
||||
export class UpdateServiceRecordDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
expectedRevision!: number;
|
||||
|
||||
@IsISO8601({ strict: true })
|
||||
serviceStartAt!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
serviceEndAt?: string;
|
||||
|
||||
@IsIn(SERVICE_RECORD_TYPES)
|
||||
serviceType!: (typeof SERVICE_RECORD_TYPES)[number];
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(5000)
|
||||
content!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(5000)
|
||||
result!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
followUpSuggestion?: string;
|
||||
|
||||
@IsIn(BENEFICIARY_CONFIRM_STATUSES)
|
||||
beneficiaryConfirmStatus!: (typeof BENEFICIARY_CONFIRM_STATUSES)[number];
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(9)
|
||||
@IsUUID('4', { each: true })
|
||||
attachmentPublicIds!: string[];
|
||||
}
|
||||
|
||||
export class CreateUploadIntentDto {
|
||||
@IsUUID('4')
|
||||
clientRequestId!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(1, 255)
|
||||
filename!: string;
|
||||
|
||||
@IsIn(['image/jpeg', 'image/png', 'image/webp'])
|
||||
mimeType!: 'image/jpeg' | 'image/png' | 'image/webp';
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10 * 1024 * 1024)
|
||||
size!: number;
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^[a-f0-9]{64}$/)
|
||||
sha256?: string;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
BadRequestException,
|
||||
Catch,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
type ExceptionFilter,
|
||||
} from '@nestjs/common';
|
||||
import { ThrottlerException } from '@nestjs/throttler';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { API_ERROR_CODES, type ApiErrorCode, type ApiErrorResponse } from '@lehuo/contracts';
|
||||
|
||||
import { ApplicationError } from './application-error';
|
||||
import type { RequestContext } from './request-context';
|
||||
|
||||
@Catch()
|
||||
export class ApiExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(ApiExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const http = host.switchToHttp();
|
||||
const request = http.getRequest<RequestContext>();
|
||||
const response = http.getResponse<Response>();
|
||||
const mapped = this.mapException(exception);
|
||||
if (mapped.status >= 500) {
|
||||
const name = exception instanceof Error ? exception.name : 'UnknownError';
|
||||
this.logger.error(`Unhandled ${name}; requestId=${request.requestId}`);
|
||||
}
|
||||
response.status(mapped.status).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: mapped.code,
|
||||
message: mapped.message,
|
||||
...(mapped.details ? { details: mapped.details } : {}),
|
||||
},
|
||||
requestId: request.requestId,
|
||||
} satisfies ApiErrorResponse);
|
||||
}
|
||||
|
||||
private mapException(exception: unknown): {
|
||||
status: number;
|
||||
code: ApiErrorCode;
|
||||
message: string;
|
||||
details?: Record<string, string[]>;
|
||||
} {
|
||||
if (exception instanceof ApplicationError) {
|
||||
return {
|
||||
status: exception.getStatus(),
|
||||
code: exception.code,
|
||||
message: exception.message,
|
||||
...(exception.details ? { details: exception.details } : {}),
|
||||
};
|
||||
}
|
||||
if (exception instanceof ThrottlerException) {
|
||||
return {
|
||||
status: 429,
|
||||
code: API_ERROR_CODES.RATE_LIMITED,
|
||||
message: '操作过于频繁,请稍后再试',
|
||||
};
|
||||
}
|
||||
if (exception instanceof UnauthorizedException) {
|
||||
return { status: 401, code: API_ERROR_CODES.AUTH_REQUIRED, message: '请先登录后再继续' };
|
||||
}
|
||||
if (exception instanceof ForbiddenException) {
|
||||
return { status: 403, code: API_ERROR_CODES.FORBIDDEN, message: '您暂无权限执行此操作' };
|
||||
}
|
||||
if (exception instanceof BadRequestException) {
|
||||
const response = exception.getResponse();
|
||||
const messages =
|
||||
typeof response === 'object' && response && 'message' in response ? response.message : null;
|
||||
return {
|
||||
status: 400,
|
||||
code: API_ERROR_CODES.VALIDATION_ERROR,
|
||||
message: '请检查填写内容后重试',
|
||||
...(Array.isArray(messages)
|
||||
? {
|
||||
details: {
|
||||
fields: messages.filter((item): item is string => typeof item === 'string'),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (exception instanceof HttpException) {
|
||||
return {
|
||||
status: exception.getStatus(),
|
||||
code: API_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: exception.getStatus() >= 500 ? '服务暂时不可用,请稍后重试' : exception.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 500,
|
||||
code: API_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: '服务暂时不可用,请稍后重试',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import type { ApiSuccessResponse } from '@lehuo/contracts';
|
||||
|
||||
import { RAW_RESPONSE_KEY } from './decorators/raw-response.decorator';
|
||||
import type { RequestContext } from './request-context';
|
||||
|
||||
@Injectable()
|
||||
export class ApiResponseInterceptor implements NestInterceptor {
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const raw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (raw) return next.handle();
|
||||
const request = context.switchToHttp().getRequest<RequestContext>();
|
||||
return next.handle().pipe(
|
||||
map((data: unknown): ApiSuccessResponse<unknown> => ({
|
||||
success: true,
|
||||
data,
|
||||
requestId: request.requestId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { HttpException, type HttpStatus } from '@nestjs/common';
|
||||
|
||||
import type { ApiErrorCode } from '@lehuo/contracts';
|
||||
|
||||
export class ApplicationError extends HttpException {
|
||||
constructor(
|
||||
readonly code: ApiErrorCode,
|
||||
message: string,
|
||||
status: HttpStatus,
|
||||
readonly details?: Record<string, string[]>,
|
||||
) {
|
||||
super(message, status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import type { AuthContext, RequestContext } from '../request-context';
|
||||
|
||||
export const CurrentAuth = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthContext => {
|
||||
const request = context.switchToHttp().getRequest<RequestContext>();
|
||||
if (!request.auth) throw new Error('Authentication context is unavailable');
|
||||
return request.auth;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const RAW_RESPONSE_KEY = 'rawResponse';
|
||||
export const RawResponse = (): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(RAW_RESPONSE_KEY, true);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
import type { PermissionCode } from '@lehuo/contracts';
|
||||
|
||||
export const REQUIRED_PERMISSIONS_KEY = 'requiredPermissions';
|
||||
export const RequirePermissions = (
|
||||
...permissions: PermissionCode[]
|
||||
): MethodDecorator & ClassDecorator => SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions);
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import type { PermissionCode, RoleCode, UserStatus, UserType } from '@lehuo/contracts';
|
||||
|
||||
import type { SessionChannel } from '../generated/prisma/enums';
|
||||
|
||||
export interface AuthContext {
|
||||
userId: bigint;
|
||||
userPublicId: string;
|
||||
status: UserStatus;
|
||||
userType: UserType;
|
||||
displayName: string | null;
|
||||
roles: RoleCode[];
|
||||
permissions: PermissionCode[];
|
||||
sessionId: bigint;
|
||||
channel: SessionChannel;
|
||||
rawToken: string;
|
||||
}
|
||||
|
||||
export interface RequestContext extends Request {
|
||||
requestId: string;
|
||||
auth?: AuthContext;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { NextFunction, Response } from 'express';
|
||||
|
||||
import type { RequestContext } from './request-context';
|
||||
|
||||
const REQUEST_ID_PATTERN = /^[a-zA-Z0-9_-]{8,64}$/;
|
||||
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(request: RequestContext, response: Response, next: NextFunction): void {
|
||||
const supplied = request.header('x-request-id');
|
||||
request.requestId = supplied && REQUEST_ID_PATTERN.test(supplied) ? supplied : randomUUID();
|
||||
response.setHeader('x-request-id', request.requestId);
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { catchError, finalize, type Observable, throwError } from 'rxjs';
|
||||
import { RequestMetricsService } from './request-metrics.service';
|
||||
@Injectable()
|
||||
export class RequestMetricsInterceptor implements NestInterceptor {
|
||||
constructor(@Inject(RequestMetricsService) private readonly metrics: RequestMetricsService) {}
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const start = performance.now();
|
||||
let status = 200;
|
||||
return next.handle().pipe(
|
||||
catchError((e: unknown) => {
|
||||
status =
|
||||
typeof e === 'object' && e !== null && 'status' in e && typeof e.status === 'number'
|
||||
? e.status
|
||||
: 500;
|
||||
return throwError(() => e);
|
||||
}),
|
||||
finalize(() => {
|
||||
const response = context.switchToHttp().getResponse<{ statusCode: number }>();
|
||||
this.metrics.record(
|
||||
status === 200 ? response.statusCode : status,
|
||||
performance.now() - start,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@Injectable()
|
||||
export class RequestMetricsService {
|
||||
private count = 0;
|
||||
private errors = 0;
|
||||
private durations: number[] = [];
|
||||
record(status: number, duration: number) {
|
||||
this.count++;
|
||||
if (status >= 500) this.errors++;
|
||||
this.durations.push(duration);
|
||||
if (this.durations.length > 1000) this.durations.shift();
|
||||
}
|
||||
snapshot() {
|
||||
const sorted = [...this.durations].sort((a, b) => a - b);
|
||||
return {
|
||||
requestCount: this.count,
|
||||
serverErrorCount: this.errors,
|
||||
p95Milliseconds: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
|
||||
sampleSize: sorted.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { PERMISSION_CODES } from '@lehuo/contracts';
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { RequirePermissions } from './decorators/require-permissions.decorator';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { RequestMetricsService } from './request-metrics.service';
|
||||
@Controller('api/v1/admin/system')
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
export class SystemMetricsController {
|
||||
constructor(@Inject(RequestMetricsService) private readonly metrics: RequestMetricsService) {}
|
||||
@Get('metrics') @RequirePermissions(PERMISSION_CODES.SYSTEM_MANAGE) get() {
|
||||
return {
|
||||
...this.metrics.snapshot(),
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
memory: process.memoryUsage().rss,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { AppConfigService } from './app-config.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AppConfigService],
|
||||
exports: [AppConfigService],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user