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