Files
yuyueguahao/docs/superpowers/plans/2026-06-14-phase5-backend-driven-home.md
T
2026-07-05 14:43:34 +08:00

1271 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 5 · 后端驱动首页重构 + 中心模块 + 医生/科室下线
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让后端完全驱动首页(顶部轮播 + 3 中心卡片 + 专家模块), 3 中心点击跳中心简介(后端可编辑), 移除 doctor/department 模块, 修复 admin 专家 404。
**Architecture:**
- 后端: 新增 `centers` 业务域 (4 文件, 类比 expert 模块) + 新增 `admin/experts` 鉴权代理; 删除 `doctor`/`department` 模块, 移除 homeConfig.heroExperts, 重建 schema 迁移
- Admin: 删除"医生/科室"侧栏, 新增"中心管理"; "专家资源" 列表/编辑走 `/api/v1/admin/experts`
- 小程序: 顶部 Swiper banners, 3 中心卡跳 `/pages/center/index?id=xxx`, 专家区域从 `/api/v1/experts` 实时拉, 预约页科室硬编码 肛肠/胃肠, 删除 doctor/department 引用
**Tech Stack:** Taro 4.1 + React 18 + TypeScript 5 + Sass; Express 4 + mysql2 + dayjs; Vanilla JS Admin SPA
---
## File Map
**新增 (server)**
- `server/src/modules/center/{route.js, service.js, dto.js, README.md}`
- `server/src/modules/expert/route.admin.js`
- `server/src/seeds/centers.js`
**修改 (server)**
- `server/src/index.js` — mount `centerRouter` + `expertAdminRouter`, unmount `doctorRouter` + `departmentRouter`, 移除 seedDoctors/seedDepartments
- `server/src/modules/index.js` — 导出新 router, 删 doctor/department 导出
- `server/src/modules/config/route.js` — 移除 heroExperts 相关端点
- `server/src/seeds/homeConfig.js` — 移除 heroExperts 字段
- `server/src/schema.sql` — DROP TABLE doctors/departments, CREATE TABLE centers
- `server/src/swagger/spec.js` — 注册新 schema
**修改 (admin)**
- `admin/index.html` — 侧栏删除"医生管理/科室管理", 新增"中心管理"
- `admin/app.js` — 删 renderDoctors, 加 renderCenters; experts 模块调用统一改 `/api/admin/experts` (沿用现有 v1 注入)
**新增 (前端)**
- `src/pages/center/{index.config.ts, index.tsx, index.module.scss}`
**修改 (前端)**
- `src/pages/home/index.tsx` — 移除 heroExperts, 顶部改用 BannerSwiper, 3 中心跳 center 页, 专家区调 `/api/v1/experts`
- `src/pages/home/index.module.scss` — 新增 `bannerSwiper` / `bannerImg` / `bannerDot` 样式
- `src/pages/appointment/index.tsx` — DEPARTMENT_OPTIONS 硬编码 2 项, 删 departmentList
- `src/types/homeConfig.ts` — 删 HeroExpert/ExpertTeamItem (或保留 deprecated alias)
- `src/types/center.ts` (新增)
- `src/services/center.ts` (新增)
- `src/services/expert.ts` (新增)
- `src/services/banner.ts` (新增)
- `src/data/appointments.ts` — 删 departmentId 引用 (如存在)
- 删除: `src/data/doctors.ts`, `src/data/departments.ts`, `src/services/doctor.ts`, `src/services/department.ts`, `src/types/doctor.ts`, `src/types/department.ts`
- `src/app.config.ts` — 注册 center 路由
---
## Task 1: 后端 `centers` 模块 (CRUD + 公开列表)
**Files:**
- Create: `server/src/modules/center/route.js`
- Create: `server/src/modules/center/service.js`
- Create: `server/src/modules/center/dto.js`
- Create: `server/src/modules/center/README.md`
- Create: `server/src/seeds/centers.js`
- Create: `docs/api/v1/center.md`
- [ ] **Step 1.1: 写 schema 迁移 SQL 片段 (供 Task 8 一并执行)**
```sql
-- server/migrations/2026-06-14-phase5-centers.sql
CREATE TABLE IF NOT EXISTS centers (
id VARCHAR(32) PRIMARY KEY,
name VARCHAR(64) NOT NULL COMMENT '中心名称: 肛肠中心/胃肠中心/内镜中心',
slug VARCHAR(64) NOT NULL UNIQUE COMMENT '路由 slug: gangchang/weichang/neijing',
icon VARCHAR(16) DEFAULT '' COMMENT 'emoji',
intro TEXT COMMENT '简短介绍, 列表展示',
description TEXT COMMENT '完整描述, 详情页展示',
highlights JSON COMMENT '亮点数组, 如 ["国家级重点专科", "300+ 临床案例"]',
sort INT DEFAULT 99,
enabled TINYINT(1) DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
- [ ] **Step 1.2: 写 service.js**
```js
/// modules/center/service.js
const { getPool } = require('../../db');
const list = async (filter = {}) => {
const where = [];
const params = [];
if (filter.enabled === 'true') where.push('enabled = 1');
if (filter.enabled === 'false') where.push('enabled = 0');
const sql = `SELECT * FROM centers ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY sort ASC, id ASC`;
const [rows] = await getPool().query(sql, params);
return rows;
};
const findById = async (id) => {
const [rows] = await getPool().query('SELECT * FROM centers WHERE id = ?', [id]);
return rows[0] || null;
};
const findBySlug = async (slug) => {
const [rows] = await getPool().query('SELECT * FROM centers WHERE slug = ?', [slug]);
return rows[0] || null;
};
const create = async (id, fields) => {
await getPool().query(
`INSERT INTO centers (id, name, slug, icon, intro, description, highlights, sort, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, fields.name, fields.slug, fields.icon || '', fields.intro || '', fields.description || '',
JSON.stringify(fields.highlights || []), Number(fields.sort) || 99, fields.enabled === false ? 0 : 1]
);
return findById(id);
};
const update = async (id, patch) => {
const map = { name: 'name', slug: 'slug', icon: 'icon', intro: 'intro', description: 'description' };
const fields = [];
const params = [];
for (const [k, col] of Object.entries(map)) {
if (patch[k] !== undefined) fields.push(`${col} = ?`), params.push(patch[k]);
}
if (patch.highlights !== undefined) { fields.push('highlights = ?'); params.push(JSON.stringify(patch.highlights)); }
if (patch.sort !== undefined) { fields.push('sort = ?'); params.push(Number(patch.sort)); }
if (patch.enabled !== undefined) { fields.push('enabled = ?'); params.push(patch.enabled ? 1 : 0); }
if (!fields.length) return findById(id);
params.push(id);
await getPool().query(`UPDATE centers SET ${fields.join(', ')} WHERE id = ?`, params);
return findById(id);
};
const remove = async (id) => {
const row = await findById(id);
if (!row) return null;
await getPool().query('DELETE FROM centers WHERE id = ?', [id]);
return row;
};
module.exports = { list, findById, findBySlug, create, update, remove };
```
- [ ] **Step 1.3: 写 dto.js**
```js
/// modules/center/dto.js
const toDto = (row) => ({
id: row.id,
name: row.name,
slug: row.slug,
icon: row.icon || '',
intro: row.intro || '',
description: row.description || '',
highlights: typeof row.highlights === 'string' ? JSON.parse(row.highlights || '[]') : (row.highlights || []),
sort: Number(row.sort) || 99,
enabled: !!row.enabled,
createdAt: row.created_at,
updatedAt: row.updated_at
});
const toCreateDto = (body) => ({
name: body.name,
slug: body.slug,
icon: body.icon,
intro: body.intro,
description: body.description,
highlights: body.highlights,
sort: body.sort,
enabled: body.enabled
});
module.exports = { toDto, toCreateDto };
```
- [ ] **Step 1.4: 写 route.js**
```js
/// modules/center/route.js
const express = require('express');
const router = express.Router();
const { success, fail } = require('../../utils/response');
const { authRequired } = require('../../utils/auth');
const service = require('./service');
const { toDto, toCreateDto } = require('./dto');
/** GET /api/v1/centers 公开列表 */
router.get('/', async (req, res) => {
try {
const rows = await service.list({ enabled: req.query.enabled });
res.json(success(rows.map(toDto)));
} catch (err) {
console.error('[Center] list error', err);
res.status(500).json(fail('服务器内部错误'));
}
});
/** GET /api/v1/centers/:id 详情 */
router.get('/:id', async (req, res) => {
try {
const row = await service.findById(req.params.id);
if (!row) return res.status(404).json(fail('中心不存在', 404));
res.json(success(toDto(row)));
} catch (err) {
console.error('[Center] detail error', err);
res.status(500).json(fail('服务器内部错误'));
}
});
/** POST /api/v1/centers 新增 (管理后台) */
router.post('/', authRequired, async (req, res) => {
try {
const body = req.body || {};
if (!body.name || !body.slug) return res.status(400).json(fail('name 和 slug 必填'));
const id = `c-${Date.now()}`;
const row = await service.create(id, toCreateDto(body));
res.json(success(toDto(row), '创建成功'));
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(400).json(fail('slug 已存在'));
console.error('[Center] create error', err);
res.status(500).json(fail('服务器内部错误'));
}
});
/** PUT /api/v1/centers/:id 更新 */
router.put('/:id', authRequired, async (req, res) => {
try {
const exists = await service.findById(req.params.id);
if (!exists) return res.status(404).json(fail('中心不存在', 404));
const row = await service.update(req.params.id, req.body || {});
res.json(success(toDto(row), '更新成功'));
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(400).json(fail('slug 已存在'));
console.error('[Center] update error', err);
res.status(500).json(fail('服务器内部错误'));
}
});
/** DELETE /api/v1/centers/:id 删除 */
router.delete('/:id', authRequired, async (req, res) => {
try {
const row = await service.remove(req.params.id);
if (!row) return res.status(404).json(fail('中心不存在', 404));
res.json(success(toDto(row), '删除成功'));
} catch (err) {
console.error('[Center] delete error', err);
res.status(500).json(fail('服务器内部错误'));
}
});
module.exports = router;
```
- [ ] **Step 1.5: 写 seeds/centers.js (3 个初始中心, 肛肠/胃肠/内镜)**
```js
/// seeds/centers.js
const centers = [
{
id: 'c-gangchang',
name: '肛肠中心',
slug: 'gangchang',
icon: '🩺',
intro: '国家重点专科 · 30 年品牌',
description: '苏州肛泰中医院肛肠中心是国家中医药管理局重点专科, 拥有 30 年肛肠疾病诊疗经验, 汇聚王秋萍、闻新阁等国内知名专家, 配备肛肠综合治疗仪、电子结肠镜等先进设备。',
highlights: ['国家中医药管理局重点专科', '30 年专科品牌', '微创无痛技术', '医保定点'],
sort: 1,
enabled: 1
},
{
id: 'c-weichang',
name: '胃肠中心',
slug: 'weichang',
icon: '🧬',
intro: '江苏省临床重点专科 · 微创特色',
description: '胃肠中心是江苏省临床重点专科, 配备日本奥林巴斯电子胃肠镜, 开展无痛胃肠镜检查、胃肠道肿瘤微创手术、胃肠功能性疾病中西医结合治疗。',
highlights: ['江苏省临床重点专科', '无痛胃肠镜', '中西医结合', '24h 急诊'],
sort: 2,
enabled: 1
},
{
id: 'c-neijing',
name: '内镜中心',
slug: 'neijing',
icon: '🖥️',
intro: '省内首席 · 设备先进',
description: '内镜中心拥有日本奥林巴斯 290 胃肠镜系统、胶囊内镜、超声内镜等国际先进设备, 开展常规及无痛胃肠镜检查、内镜下微创治疗 (EMR/ESD)。',
highlights: ['奥林巴斯 290 系统', '胶囊内镜', 'EMR/ESD 微创', '三甲名医亲诊'],
sort: 3,
enabled: 1
}
];
const seedCenters = async (pool) => {
const conn = await pool.getConnection();
try {
const [rows] = await conn.query('SELECT COUNT(*) AS c FROM centers');
if (rows[0].c > 0) {
console.log('[Seed] centers: skipped (already has data)');
return;
}
for (const c of centers) {
await conn.query(
`INSERT INTO centers (id, name, slug, icon, intro, description, highlights, sort, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[c.id, c.name, c.slug, c.icon, c.intro, c.description, JSON.stringify(c.highlights), c.sort, c.enabled]
);
}
console.log(`[Seed] centers: inserted ${centers.length}`);
} finally {
conn.release();
}
};
module.exports = { seedCenters };
```
- [ ] **Step 1.6: 写 modules/center/README.md**
```markdown
# Center · 中心介绍模块
管理首页"3 中心"卡片对应的中心介绍 (肛肠中心/胃肠中心/内镜中心), 后台可编辑 slug/name/intro/description/highlights。
## 接口
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | /api/v1/centers | 否 | 公开列表 (支持 ?enabled=true 过滤) |
| GET | /api/v1/centers/:id | 否 | 详情 (id 或 slug 均可) |
| POST | /api/v1/centers | 是 | 新增 |
| PUT | /api/v1/centers/:id | 是 | 更新 |
| DELETE | /api/v1/centers/:id | 是 | 删除 |
## DTO 字段
- `name` 中心名称
- `slug` URL slug, 唯一, 用于小程序 center 页 query: `/pages/center/index?slug=gangchang`
- `icon` emoji
- `intro` 列表展示用一句话简介
- `description` 详情页完整描述 (支持换行)
- `highlights` string[] 亮点列表
- `sort` 排序 (升序)
- `enabled` 是否启用
```
- [ ] **Step 1.7: 写 docs/api/v1/center.md**
```markdown
# Center · 中心介绍
## GET /api/v1/centers
返回所有启用的中心。
**Response 200**
```json
{
"code": 0,
"data": [
{
"id": "c-gangchang",
"name": "肛肠中心",
"slug": "gangchang",
"icon": "🩺",
"intro": "国家重点专科 · 30 年品牌",
"description": "...",
"highlights": ["国家中医药管理局重点专科", "30 年专科品牌"],
"sort": 1,
"enabled": true
}
]
}
```
## GET /api/v1/centers/:idOrSlug
按 id 或 slug 查询。
## POST /api/v1/centers (admin)
Body: `{ name, slug, icon?, intro?, description?, highlights?, sort?, enabled? }`
## PUT /api/v1/centers/:id (admin)
Body 同上, 仅传需要更新的字段。
## DELETE /api/v1/centers/:id (admin)
```
---
## Task 2: 后端 `expert/route.admin.js` (修复 admin 404)
**Files:**
- Create: `server/src/modules/expert/route.admin.js`
- Modify: `server/src/modules/index.js`
- Modify: `server/src/index.js`
- [ ] **Step 2.1: 写 route.admin.js (复用 expert/service.js)**
```js
/// modules/expert/route.admin.js
/// 描述:管理后台专家资源接口 (挂在 /api/v1/admin/experts)
const express = require('express');
const router = express.Router();
const { success, fail } = require('../../utils/response');
const { authRequired } = require('../../utils/auth');
const service = require('./service');
const { toDto, toCreateDto } = require('./dto');
/** GET /api/v1/admin/experts 列表 */
router.get('/', authRequired, async (req, res) => {
try {
const rows = await service.list({});
res.json(success(rows.map(toDto)));
} catch (err) {
console.error('[Expert admin] list error', err);
res.status(500).json(fail('服务器内部错误'));
}
});
/** GET /api/v1/admin/experts/:id 详情 */
router.get('/:id', authRequired, async (req, res) => {
try {
const row = await service.findById(req.params.id);
if (!row) return res.status(404).json(fail('专家不存在', 404));
res.json(success(toDto(row)));
} catch (err) {
console.error('[Expert admin] detail error', err);
res.status(500).json(fail('服务器内部错误'));
}
});
/** POST /api/v1/admin/experts 新增 */
router.post('/', authRequired, async (req, res) => {
try {
const body = req.body || {};
if (!body.name || !body.title || !body.avatar) return res.status(400).json(fail('name/title/avatar 必填'));
const id = `he-${Date.now()}`;
const row = await service.create(id, toCreateDto(body));
res.json(success(toDto(row), '创建成功'));
} catch (err) {
console.error('[Expert admin] create error', err);
res.status(500).json(fail('服务器内部错误'));
}
});
/** PUT /api/v1/admin/experts/:id 更新 */
router.put('/:id', authRequired, async (req, res) => {
try {
const exists = await service.findById(req.params.id);
if (!exists) return res.status(404).json(fail('专家不存在', 404));
const row = await service.update(req.params.id, req.body || {});
res.json(success(toDto(row), '更新成功'));
} catch (err) {
console.error('[Expert admin] update error', err);
res.status(500).json(fail('服务器内部错误'));
}
});
/** DELETE /api/v1/admin/experts/:id 删除 */
router.delete('/:id', authRequired, async (req, res) => {
try {
const row = await service.remove(req.params.id);
if (!row) return res.status(404).json(fail('专家不存在', 404));
res.json(success(toDto(row), '删除成功'));
} catch (err) {
console.error('[Expert admin] delete error', err);
res.status(500).json(fail('服务器内部错误'));
}
});
module.exports = router;
```
- [ ] **Step 2.2: 在 modules/index.js 追加 export**
```js
const expertAdminRouter = require('./expert/route.admin');
// ...其他 export
module.exports = {
// ...
expertAdminRouter
};
```
- [ ] **Step 2.3: 在 server/src/index.js mount 新路由, 删 doctor/department**
修改顶部 require:
```js
const {
hospitalRouter,
appointmentRouter,
configRouter,
expertRouter,
expertAdminRouter, // 新增
bannerRouter,
adminAuthRouter,
leadRouter,
leadAdminRouter,
centerRouter // 新增
} = require('./modules');
```
修改 mount:
```js
// 删除这两行
// app.use(`${API_PREFIX}/doctors`, doctorRouter);
// app.use(`${API_PREFIX}/departments`, departmentRouter);
// 新增这两行
app.use(`${API_PREFIX}/centers`, centerRouter);
app.use(`${API_PREFIX}/admin/experts`, expertAdminRouter);
```
删除 `start()` 里的 `seedDoctors`/`seedDepartments` 调用, 替换为 `seedCenters`。
- [ ] **Step 2.4: 本地启动验证 `curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/admin/experts`**
期望: 返回 `{ code: 0, data: [...] }`, 而不是 404。
---
## Task 3: 删除 `doctor` / `department` 模块 + schema 迁移
**Files:**
- Delete: `server/src/modules/doctor/{route.js, service.js, dto.js, README.md}`
- Delete: `server/src/modules/department/{route.js, service.js, dto.js, README.md}`
- Delete: `server/src/seeds/{doctors.js, departments.js}`
- Modify: `server/src/modules/index.js` — 删除 doctorRouter/departmentRouter 导出
- Modify: `server/src/index.js` — 移除 require + mount + seed
- Modify: `server/src/schema.sql` — DROP TABLE doctors, departments
- Modify: `server/src/swagger/spec.js` — 移除 tags 引用 (如有)
- [ ] **Step 3.1: 执行 DROP TABLE**
服务器上执行 (PM2 停止后):
```sql
DROP TABLE IF EXISTS doctors;
DROP TABLE IF EXISTS departments;
```
- [ ] **Step 3.2: 在 schema.sql 同步删除 CREATE TABLE 段 (避免重启重建)**
- [ ] **Step 3.3: 删除 4+2+2+2 = 10 个文件**
```
rm -rf server/src/modules/doctor
rm -rf server/src/modules/department
rm server/src/seeds/doctors.js
rm server/src/seeds/departments.js
```
- [ ] **Step 3.4: 验证 `npm start` 启动无 import 报错**
期望: 控制台启动日志无 `Cannot find module './doctor/route'`, 接口列表不出现 /doctors /departments。
---
## Task 4: homeConfig 移除 heroExperts / expertTeam
**Files:**
- Modify: `server/src/seeds/homeConfig.js`
- Modify: `server/src/modules/config/route.js`
- Modify: `src/types/homeConfig.ts`
- [ ] **Step 4.1: 在 seeds/homeConfig.js 删除 heroExperts / expertTeam 字段**
保留:
```js
module.exports = {
navTitle: '苏州肛泰中医院',
hero: { title, subtitle, background },
features: [3 中心卡], // id 改为 c-gangchang / c-weichang / c-neijing, link 改为 /pages/center/index?slug=xxx
infoBanner: { ... }
};
```
`features` 改为:
```js
features: [
{ id: 'f-gangchang', name: '肛肠中心', sub: '国家专科', icon: '🩺', iconColor: 'linear-gradient(135deg, #5ed3c0 0%, #2dbfa6 100%)', link: '/pages/center/index?slug=gangchang' },
{ id: 'f-weichang', name: '胃肠中心', sub: '微创特色', icon: '🧬', iconColor: 'linear-gradient(135deg, #5ed3c0 0%, #2dbfa6 100%)', link: '/pages/center/index?slug=weichang' },
{ id: 'f-neijing', name: '内镜中心', sub: '省内首席', icon: '🖥️', iconColor: 'linear-gradient(135deg, #5ed3c0 0%, #2dbfa6 100%)', link: '/pages/center/index?slug=neijing' }
]
```
- [ ] **Step 4.2: 在 modules/config/route.js 删除 PUT /hero-experts 和 PUT /expert-team 端点**
- [ ] **Step 4.3: 在 src/types/homeConfig.ts 标记 HeroExpert/ExpertTeamItem 为 @deprecated, 保留空数组以防编译错**
```ts
/** @deprecated Phase 5 起改用 /api/v1/experts, 此字段保留为空 */
heroExperts?: HeroExpert[];
/** @deprecated Phase 5 起改用 /api/v1/experts, 此字段保留为空 */
expertTeam?: ExpertTeamItem[];
```
- [ ] **Step 4.4: 验证 `GET /api/v1/config/home` 返回的 JSON 不再含 heroExperts / expertTeam 字段**
---
## Task 5: 前端 home 页改造 (顶部轮播 + 3 中心跳页 + 专家调 API)
**Files:**
- Modify: `src/pages/home/index.tsx`
- Modify: `src/pages/home/index.module.scss`
- Create: `src/services/banner.ts`
- Create: `src/services/expert.ts`
- [ ] **Step 5.1: 写 services/banner.ts**
```ts
import type { Banner } from '@/types/banner';
import { request } from './request';
export const fetchBanners = async (): Promise<Banner[]> => {
const res = await request<Banner[]>({ url: '/api/v1/banners?enabled=true' });
return (res.data as Banner[]) || [];
};
```
- [ ] **Step 5.2: 写 src/types/banner.ts**
```ts
export interface Banner {
id: string;
title: string;
subtitle?: string;
image: string;
link: string;
sort: number;
enabled: boolean;
}
```
- [ ] **Step 5.3: 写 services/expert.ts**
```ts
import type { Expert } from '@/types/expert';
import { request } from './request';
export const fetchExperts = async (): Promise<Expert[]> => {
const res = await request<Expert[]>({ url: '/api/v1/experts?enabled=true' });
return (res.data as Expert[]) || [];
};
```
- [ ] **Step 5.4: 写 src/types/expert.ts**
```ts
export interface Expert {
id: string;
name: string;
title: string;
avatar: string;
department?: string;
intro?: string;
sort?: number;
enabled?: boolean;
}
```
- [ ] **Step 5.5: 改写 home/index.tsx 顶部区块**
```tsx
// 顶部替换 heroExperts + heroPill 区域为 Swiper
import { Swiper, SwiperItem } from '@tarojs/components';
const [banners, setBanners] = useState<Banner[]>([]);
const [experts, setExperts] = useState<Expert[]>([]);
useEffect(() => {
(async () => {
try {
const [cfg, bs, ex] = await Promise.all([
fetchHomeConfig(),
fetchBanners().catch(() => []),
fetchExperts().catch(() => [])
]);
setConfig(cfg);
setBanners(bs);
setExperts(ex);
if (cfg.navTitle) Taro.setNavigationBarTitle({ title: cfg.navTitle });
} catch (err) { /* ... */ }
})();
}, []);
// 渲染
<View className={styles.hero}>
{/* nav 保留 */}
<Text className={styles.heroTitle}>{config.hero.title}</Text>
<Text className={styles.heroSubtitle}>{config.hero.subtitle}</Text>
{/* 顶部轮播 */}
{banners.length > 0 && (
<Swiper
className={styles.bannerSwiper}
indicatorDots
autoplay
interval={4000}
circular
indicatorColor="rgba(255,255,255,.5)"
indicatorActiveColor="#ffffff"
>
{banners.map((b) => (
<SwiperItem key={b.id} onClick={() => b.link && Taro.navigateTo({ url: b.link })}>
<Image className={styles.bannerImg} src={b.image} mode="aspectFill" />
</SwiperItem>
))}
</Swiper>
)}
</View>
```
- [ ] **Step 5.6: 改写 home expertTeam 区块, 数据源改 experts**
```tsx
{/* 专家团队 */}
<View className={styles.section}>
<View className={styles.sectionHeader}>
<Text className={styles.sectionTitle}>专家团队</Text>
<Text className={styles.sectionMore} onClick={() => Taro.switchTab({ url: '/pages/hospital/index' })}>更多 &gt;&gt;</Text>
</View>
<View className={styles.teamList}>
{experts.length === 0 ? (
<View style={{ padding: '60rpx 0', textAlign: 'center' }}>
<Text className={styles.teamEmpty}>暂无专家数据</Text>
</View>
) : experts.map((m) => (
<View key={m.id} className={styles.teamCard}>
<View className={styles.teamAvatarWrap}>
<Image className={styles.teamAvatar} src={m.avatar} mode="aspectFill" />
</View>
<View className={styles.teamInfo}>
<Text className={styles.teamName}>{m.name}</Text>
<Text className={styles.teamTitleText}>{m.title}</Text>
{m.intro && <Text className={styles.teamSpecialty}>{m.intro}</Text>}
</View>
<Text className={styles.teamArrow}>›</Text>
</View>
))}
</View>
</View>
```
- [ ] **Step 5.7: 修 home/index.module.scss 新增 bannerSwiper 样式**
```scss
.bannerSwiper {
margin: $spacing-5 $page-gutter 0;
height: 280rpx;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: $shadow-2;
}
.bannerImg {
width: 100%;
height: 100%;
display: block;
}
.teamEmpty {
color: $color-text-tertiary;
font-size: $font-size-sm;
letter-spacing: $letter-spacing-loose;
}
```
- [ ] **Step 5.8: 删除 home/index.tsx 中 heroPillWrap / expertRow / heroOnline 等专家相关元素**
- [ ] **Step 5.9: 验证 `npm run build:weapp` 编译通过**
---
## Task 6: 新增 center 详情页
**Files:**
- Create: `src/pages/center/index.config.ts`
- Create: `src/pages/center/index.tsx`
- Create: `src/pages/center/index.module.scss`
- Create: `src/services/center.ts`
- Create: `src/types/center.ts`
- Modify: `src/app.config.ts`
- [ ] **Step 6.1: 写 src/types/center.ts**
```ts
export interface Center {
id: string;
name: string;
slug: string;
icon: string;
intro: string;
description: string;
highlights: string[];
sort: number;
enabled: boolean;
}
```
- [ ] **Step 6.2: 写 src/services/center.ts**
```ts
import type { Center } from '@/types/center';
import { request } from './request';
export const fetchCenters = async (): Promise<Center[]> => {
const res = await request<Center[]>({ url: '/api/v1/centers?enabled=true' });
return (res.data as Center[]) || [];
};
export const fetchCenter = async (idOrSlug: string): Promise<Center> => {
const res = await request<Center>({ url: `/api/v1/centers/${idOrSlug}` });
return res.data as Center;
};
```
- [ ] **Step 6.3: 写 src/pages/center/index.config.ts**
```ts
export default {
navigationBarTitleText: '中心介绍',
navigationBarBackgroundColor: '#0a6dff',
navigationBarTextStyle: 'white'
};
```
- [ ] **Step 6.4: 写 src/pages/center/index.tsx**
```tsx
import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import styles from './index.module.scss';
import { fetchCenter } from '@/services/center';
import type { Center } from '@/types/center';
const CenterPage: React.FC = () => {
const [center, setCenter] = useState<Center | null>(null);
useEffect(() => {
const params = Taro.getCurrentInstance().router?.params;
const key = params?.slug || params?.id;
if (!key) {
Taro.showToast({ title: '缺少参数', icon: 'none' });
return;
}
Taro.setNavigationBarTitle({ title: '中心介绍' });
(async () => {
try {
const c = await fetchCenter(key);
setCenter(c);
Taro.setNavigationBarTitle({ title: c.name });
} catch (err) {
console.error('[Center] load failed', err);
Taro.showToast({ title: '加载失败', icon: 'none' });
}
})();
}, []);
if (!center) {
return (
<View className={styles.page}>
<View style={{ padding: '200rpx 0', textAlign: 'center' }}>
<Text className={styles.loading}>加载中…</Text>
</View>
</View>
);
}
return (
<ScrollView scrollY className={styles.page} enhanced showScrollbar={false}>
<View className={styles.hero}>
<Text className={styles.heroIcon}>{center.icon}</Text>
<Text className={styles.heroName}>{center.name}</Text>
<Text className={styles.heroIntro}>{center.intro}</Text>
</View>
{center.highlights?.length > 0 && (
<View className={styles.section}>
<View className={styles.sectionTitle}>
<View className={styles.sectionTitleBar} />
<Text>中心亮点</Text>
</View>
<View className={styles.highlightList}>
{center.highlights.map((h, idx) => (
<View key={idx} className={styles.highlightItem}>
<View className={styles.highlightDot} />
<Text className={styles.highlightText}>{h}</Text>
</View>
))}
</View>
</View>
)}
<View className={styles.section}>
<View className={styles.sectionTitle}>
<View className={styles.sectionTitleBar} />
<Text>关于中心</Text>
</View>
<View className={styles.descriptionCard}>
<Text className={styles.descriptionText}>{center.description}</Text>
</View>
</View>
<View className={styles.ctaWrap}>
<View className={styles.ctaBtn} onClick={() => Taro.switchTab({ url: '/pages/appointment/index' })}>
<Text>立即预约本中心</Text>
</View>
</View>
</ScrollView>
);
};
export default CenterPage;
```
- [ ] **Step 6.5: 写 src/pages/center/index.module.scss**
```scss
@use '@/styles/variables.scss' as *;
.page {
min-height: 100vh;
background: $color-bg-page;
padding-bottom: $spacing-9;
box-sizing: border-box;
}
.loading {
color: $color-text-tertiary;
font-size: $font-size-sm;
letter-spacing: $letter-spacing-loose;
}
.hero {
position: relative;
padding: $spacing-9 $page-gutter $spacing-8;
background: $gradient-hero;
color: $color-text-white;
text-align: center;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -140rpx;
right: -100rpx;
width: 400rpx;
height: 400rpx;
background: radial-gradient(circle, rgba(255,255,255,.14) 0%, transparent 65%);
border-radius: 50%;
pointer-events: none;
}
}
.heroIcon { font-size: 96rpx; display: block; }
.heroName { display: block; margin-top: $spacing-3; font-size: $font-size-xxl; font-weight: $font-weight-bold; letter-spacing: $letter-spacing-tight; }
.heroIntro { display: block; margin-top: $spacing-2; font-size: $font-size-sm; color: rgba(255,255,255,.92); letter-spacing: $letter-spacing-loose; }
.section { margin: $spacing-7 $page-gutter 0; }
.sectionTitle { display: flex; align-items: center; font-size: $font-size-lg; font-weight: $font-weight-bold; color: $color-text-primary; letter-spacing: $letter-spacing-tight; margin-bottom: $spacing-4; }
.sectionTitleBar { width: 6rpx; height: 24rpx; background: $gradient-primary; border-radius: $radius-xs; margin-right: $spacing-2; }
.highlightList { background: $color-bg-card; border-radius: $radius-lg; padding: $spacing-2 $spacing-5; box-shadow: $shadow-1; }
.highlightItem { display: flex; align-items: center; padding: $spacing-3 0; border-bottom: 2rpx solid $color-divider; &:last-child { border-bottom: none; } }
.highlightDot { width: 12rpx; height: 12rpx; border-radius: 50%; background: $color-primary; margin-right: $spacing-3; flex-shrink: 0; }
.highlightText { font-size: $font-size-md; color: $color-text-primary; }
.descriptionCard { background: $color-bg-card; border-radius: $radius-lg; padding: $spacing-5; box-shadow: $shadow-1; }
.descriptionText { font-size: $font-size-md; color: $color-text-secondary; line-height: $line-height-loose; white-space: pre-wrap; letter-spacing: $letter-spacing-normal; }
.ctaWrap { margin: $spacing-8 $page-gutter 0; }
.ctaBtn {
@include button-reset;
width: 100%;
height: $button-height-lg;
background: $gradient-primary;
color: $color-text-white;
border-radius: $radius-button;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
letter-spacing: $letter-spacing-loose;
box-shadow: $shadow-glow;
@include press-feedback;
}
```
- [ ] **Step 6.6: 在 src/app.config.ts 注册路由**
```ts
// pages 数组追加
{
path: 'pages/center/index',
// 注意: center 是非 tab 页, 单独 route
}
```
- [ ] **Step 6.7: 验证 `npm run build:weapp` 编译通过**
---
## Task 7: 预约表单硬编码 2 个科室, 删除 doctor/department 引用
**Files:**
- Modify: `src/pages/appointment/index.tsx`
- Modify: `src/pages/hospital/index.tsx`
- Modify: `src/pages/hospital/index.module.scss`
- Delete: `src/data/doctors.ts`
- Delete: `src/data/departments.ts`
- Delete: `src/services/doctor.ts` (如有)
- Delete: `src/services/department.ts` (如有)
- Delete: `src/types/doctor.ts`
- Delete: `src/types/department.ts`
- [ ] **Step 7.1: 在 appointment/index.tsx 保留 DEPARTMENT_OPTIONS 硬编码**
```ts
const DEPARTMENT_OPTIONS: { value: DepartmentChoice; label: string; icon: string }[] = [
{ value: '肛肠', label: '肛肠中心', icon: '🩺' },
{ value: '胃肠', label: '胃肠中心', icon: '🧬' }
];
```
删除 `import { fetchDepartmentList }` 及其 useEffect 调用。
- [ ] **Step 7.2: 改写 hospital/index.tsx 显示 3 中心 (调 /api/v1/centers)**
```tsx
// 替换 dept tab 内容
import { fetchCenters } from '@/services/center';
import type { Center } from '@/types/center';
const [centers, setCenters] = useState<Center[]>([]);
useEffect(() => {
(async () => {
try {
const [hList, cList] = await Promise.all([fetchHospitalList(), fetchCenters()]);
setHospital(hList[0]);
setCenters(cList);
} catch (err) { /* ... */ }
})();
}, []);
// intro tab 保留, dept tab 改为 centers
{activeTab === 'dept' && (
<>
<Text className={styles.panelTitle}>中心设置(共 {centers.length} 个)</Text>
<View className={styles.deptGrid}>
{centers.map((c) => (
<View key={c.id} className={styles.deptItem} onClick={() => Taro.navigateTo({ url: `/pages/center/index?slug=${c.slug}` })}>
<Text className={styles.deptIcon}>{c.icon}</Text>
<Text className={styles.deptName}>{c.name}</Text>
<Text className={styles.deptCount}>{c.highlights?.[0] || '查看详情'}</Text>
</View>
))}
</View>
</>
)}
```
- [ ] **Step 7.3: 验证 `npm run build:weapp` 编译无 TS 报错**
- [ ] **Step 7.4: 删除 4 个文件**
```bash
rm src/data/doctors.ts
rm src/data/departments.ts
rm src/services/doctor.ts # 确认无引用后
rm src/services/department.ts
rm src/types/doctor.ts
rm src/types/department.ts
```
---
## Task 8: admin 改造 (侧栏 + 中心管理)
**Files:**
- Modify: `admin/index.html` — 侧栏导航
- Modify: `admin/app.js` — 删 renderDoctors, 加 renderCenters
- [ ] **Step 8.1: admin/index.html 改侧栏**
```html
<!-- 删除这两行 -->
<!-- <a class="side-link" data-route="doctors">...</a> -->
<!-- <a class="side-link" data-route="departments">...</a> -->
<!-- 在 experts 之前插入 -->
<a class="side-link" data-route="centers"><span class="side-icon">🏛️</span><span>中心管理</span></a>
```
- [ ] **Step 8.2: admin/app.js 删 renderDoctors 函数 (整段, 包含 bindDoctors)**
- [ ] **Step 8.3: admin/app.js 删 renderDepartments 函数 + DEPT_OPTIONS 中除 肛肠/胃肠 外的项 (admin 已硬编码引用) 或保留全量**
为简化, **删除 DEPT_OPTIONS** (前端已硬编码)。
- [ ] **Step 8.4: admin/app.js 加 renderCenters 函数**
```js
const renderCenters = async () => {
const main = $('#appMain');
main.innerHTML = `
<div class="page-head">
<h2>中心管理</h2>
<button class="btn btn-primary" id="addCenterBtn">+ 新增中心</button>
</div>
<div class="card">
<table class="data-table" id="centersTable">
<thead><tr><th>名称</th><th>Slug</th><th>Icon</th><th>简介</th><th>排序</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="centersTbody"><tr><td colspan="7" class="muted">加载中…</td></tr></tbody>
</table>
</div>
`;
$('#addCenterBtn').onclick = () => openCenterModal();
try {
const list = await api('/api/centers');
const tbody = $('#centersTbody');
if (!list?.length) { tbody.innerHTML = '<tr><td colspan="7" class="muted">暂无数据</td></tr>'; return; }
tbody.innerHTML = list.map(c => `
<tr>
<td>${escapeHtml(c.name)}</td>
<td><code>${escapeHtml(c.slug)}</code></td>
<td>${escapeHtml(c.icon)}</td>
<td>${escapeHtml(c.intro)}</td>
<td>${c.sort}</td>
<td><span class="tag ${c.enabled ? 'tag-success' : 'tag-warning'}">${c.enabled ? '启用' : '停用'}</span></td>
<td>
<button class="btn btn-sm" data-edit="${c.id}">编辑</button>
<button class="btn btn-sm btn-danger" data-del="${c.id}">删除</button>
</td>
</tr>
`).join('');
$$('button[data-edit]').forEach(b => b.onclick = () => openCenterModal(list.find(c => c.id === b.dataset.edit)));
$$('button[data-del]').forEach(b => b.onclick = async () => {
if (!confirm('确认删除?')) return;
try { await api(`/api/admin/centers/${b.dataset.del}`, { method: 'DELETE' }); toast('已删除', 'success'); reloadPage('centers'); }
catch (e) { toast(e.message, 'error'); }
});
} catch (e) { toast(e.message, 'error'); }
};
const openCenterModal = (c) => {
const isEdit = !!c;
const form = `
<div class="form-field"><label>名称</label><input id="cfName" value="${escapeHtml(c?.name || '')}" required /></div>
<div class="form-field"><label>Slug (英文, 唯一)</label><input id="cfSlug" value="${escapeHtml(c?.slug || '')}" required /></div>
<div class="form-field"><label>Icon (emoji)</label><input id="cfIcon" value="${escapeHtml(c?.icon || '')}" /></div>
<div class="form-field"><label>简介 (列表展示)</label><textarea id="cfIntro" rows="2">${escapeHtml(c?.intro || '')}</textarea></div>
<div class="form-field"><label>完整描述 (详情页)</label><textarea id="cfDesc" rows="6">${escapeHtml(c?.description || '')}</textarea></div>
<div class="form-field"><label>亮点 (一行一个)</label><textarea id="cfHighlights" rows="4">${(c?.highlights || []).join('\\n')}</textarea></div>
<div class="form-field"><label>排序</label><input id="cfSort" type="number" value="${c?.sort ?? 99}" /></div>
<div class="form-field"><label><input id="cfEnabled" type="checkbox" ${c?.enabled !== false ? 'checked' : ''} /> 启用</label></div>
`;
openModal({ title: isEdit ? '编辑中心' : '新增中心', body: form, onConfirm: async () => {
const body = {
name: $('#cfName').value.trim(),
slug: $('#cfSlug').value.trim(),
icon: $('#cfIcon').value.trim(),
intro: $('#cfIntro').value.trim(),
description: $('#cfDesc').value.trim(),
highlights: $('#cfHighlights').value.split('\\n').map(s => s.trim()).filter(Boolean),
sort: Number($('#cfSort').value) || 99,
enabled: $('#cfEnabled').checked
};
if (!body.name || !body.slug) return toast('名称和 Slug 必填', 'error');
try {
if (isEdit) await api(`/api/admin/centers/${c.id}`, { method: 'PUT', body: JSON.stringify(body) });
else await api('/api/admin/centers', { method: 'POST', body: JSON.stringify(body) });
toast('已保存', 'success'); closeModal(); reloadPage('centers');
} catch (e) { toast(e.message, 'error'); }
}});
};
```
> 注: admin 的 `api()` 会自动加 `/v1` 前缀, 调用 `/api/centers` → `/api/v1/centers`, 但需要鉴权; 而我们 mount 的是 `/api/v1/admin/centers`。**所以还需 Task 8.5**。
- [ ] **Step 8.5: 后端新增 `server/src/modules/center/route.admin.js` (鉴权代理) 或让 center route 共用**
参考 `expert/route.admin.js` 模式, 把 POST/PUT/DELETE 鉴权化。或更简单: **在原 `route.js` 上把鉴权加到写接口, 读保持公开** (本计划采用此方案, 减少文件数)。
修改 `server/src/modules/center/route.js`:
```js
router.post('/', authRequired, async (req, res) => { ... });
router.put('/:id', authRequired, async (req, res) => { ... });
router.delete('/:id', authRequired, async (req, res) => { ... });
```
admin app.js 调用改为 `/api/centers` (POST/PUT/DELETE) → 实际 `/api/v1/centers` (带 token)。
- [ ] **Step 8.6: 在 admin/app.js 的 routes 注册表加 centers**
```js
const routes = {
// ...
centers: renderCenters,
// ...
};
```
- [ ] **Step 8.7: 浏览器登录 admin, 进入"中心管理", 新增/编辑/删除 3 个中心, 验证 OK**
---
## Task 9: 端到端验证
- [ ] **Step 9.1: 重启 PM2**
```bash
ssh [email protected] 'cd /path && pm2 delete yuyueguahao-api; pm2 start server/src/index.js --name yuyueguahao-api'
```
- [ ] **Step 9.2: 验证后端**
```bash
curl -s http://124.221.67.199:3000/api/v1/centers | jq .
curl -s 'http://124.221.67.199:3000/api/v1/banners?enabled=true' | jq .
curl -s 'http://124.221.67.199:3000/api/v1/experts?enabled=true' | jq .
curl -s http://124.221.67.199:3000/api/v1/config/home | jq .
curl -s -H "Authorization: Bearer $TOKEN" http://124.221.67.199:3000/api/v1/admin/experts | jq .
```
期望:
- `/centers` 返回 3 条 (肛肠/胃肠/内镜)
- `/banners` 返回种子数据
- `/experts` 返回种子数据
- `/config/home` 不再含 heroExperts / expertTeam
- `/admin/experts` 不再 404
- [ ] **Step 9.3: 微信开发者工具打开 dist/ 预览**
- [ ] **Step 9.4: 验证 6 个流程**
1. 首页顶部显示 Swiper 轮播
2. 首页 3 中心卡 (肛肠/胃肠/内镜) 点击 → 跳 center 页
3. center 页显示 name/intro/highlights/description, 底部"立即预约本中心"跳 appointment
4. 专家区域从 /api/v1/experts 拉取, admin 改后小程序刷新即生效
5. 预约页"就诊科室"硬编码 2 选项
6. admin "专家资源" 列表/编辑/删除正常
7. admin "中心管理" CRUD 正常
- [ ] **Step 9.5: 部署 dist**
```bash
# 选 A: 推 dist 到服务器
# 选 B: miniprogram-ci 上传
# 选 C: 仅本地构建
```
---
## Risks & Mitigations
| 风险 | 缓解 |
|---|---|
| 删除 doctor/department 后 appointment 表的 `departmentName` 字段缺语义 | 保留字符串硬编码 '肛肠'/'胃肠', 不再 fetch list |
| Admin 删除 2 个侧栏后, 已登录用户 localStorage 路由不匹配 | 改 `routes` 表 + 重启浏览器, 不缓存路由 |
| 后端 DROP TABLE 后旧数据丢失 | Phase 4 已是 Mock, 损失可接受; 上线前确认无生产数据 |
| center slug 重复导致 INSERT 失败 | service 抛 ER_DUP_ENTRY, route 转 400, 提示"slug 已存在" |
| 小程序 build 缓存导致新页面未生效 | `rm -rf dist .taro-cache && npm run build:weapp` |
## 验收
- 6 页 + center 新页编译通过
- 后端 `/api/v1/centers` / `/api/v1/admin/experts` / `/api/v1/banners` / `/api/v1/experts` 全 200
- Admin "中心管理" CRUD OK, "专家资源" 列表 OK
- 小程序首页顶部轮播 + 3 中心跳页 + 专家实时刷新
- 预约页 2 选项 (肛肠/胃肠) 硬编码可用
- doctor / department 模块代码完全从仓库删除