1271 lines
42 KiB
Markdown
1271 lines
42 KiB
Markdown
# 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' })}>更多 >></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 模块代码完全从仓库删除
|