# 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 => { const res = await request({ 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 => { const res = await request({ 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([]); const [experts, setExperts] = useState([]); 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) { /* ... */ } })(); }, []); // 渲染 {/* nav 保留 */} {config.hero.title} {config.hero.subtitle} {/* 顶部轮播 */} {banners.length > 0 && ( {banners.map((b) => ( b.link && Taro.navigateTo({ url: b.link })}> ))} )} ``` - [ ] **Step 5.6: 改写 home expertTeam 区块, 数据源改 experts** ```tsx {/* 专家团队 */} 专家团队 Taro.switchTab({ url: '/pages/hospital/index' })}>更多 >> {experts.length === 0 ? ( 暂无专家数据 ) : experts.map((m) => ( {m.name} {m.title} {m.intro && {m.intro}} › ))} ``` - [ ] **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 => { const res = await request({ url: '/api/v1/centers?enabled=true' }); return (res.data as Center[]) || []; }; export const fetchCenter = async (idOrSlug: string): Promise
=> { const res = await request
({ 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
(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 ( 加载中… ); } return ( {center.icon} {center.name} {center.intro} {center.highlights?.length > 0 && ( 中心亮点 {center.highlights.map((h, idx) => ( {h} ))} )} 关于中心 {center.description} Taro.switchTab({ url: '/pages/appointment/index' })}> 立即预约本中心 ); }; 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([]); 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' && ( <> 中心设置(共 {centers.length} 个) {centers.map((c) => ( Taro.navigateTo({ url: `/pages/center/index?slug=${c.slug}` })}> {c.icon} {c.name} {c.highlights?.[0] || '查看详情'} ))} )} ``` - [ ] **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 🏛️中心管理 ``` - [ ] **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 = `

中心管理

名称SlugIcon简介排序状态操作
加载中…
`; $('#addCenterBtn').onclick = () => openCenterModal(); try { const list = await api('/api/centers'); const tbody = $('#centersTbody'); if (!list?.length) { tbody.innerHTML = '暂无数据'; return; } tbody.innerHTML = list.map(c => ` ${escapeHtml(c.name)} ${escapeHtml(c.slug)} ${escapeHtml(c.icon)} ${escapeHtml(c.intro)} ${c.sort} ${c.enabled ? '启用' : '停用'} `).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 = `
`; 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 root@124.221.67.199 '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 模块代码完全从仓库删除