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

42 KiB
Raw Blame History

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 一并执行)

-- 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
/// 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
/// 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
/// 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 个初始中心, 肛肠/胃肠/内镜)
/// 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
# 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
# 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
const expertAdminRouter = require('./expert/route.admin');
// ...其他 export
module.exports = {
  // ...
  expertAdminRouter
};
  • Step 2.3: 在 server/src/index.js mount 新路由, 删 doctor/department

修改顶部 require:

const {
  hospitalRouter,
  appointmentRouter,
  configRouter,
  expertRouter,
  expertAdminRouter,   // 新增
  bannerRouter,
  adminAuthRouter,
  leadRouter,
  leadAdminRouter,
  centerRouter          // 新增
} = require('./modules');

修改 mount:

// 删除这两行
// 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 停止后):

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 字段

保留:

module.exports = {
  navTitle: '苏州肛泰中医院',
  hero: { title, subtitle, background },
  features: [3 中心卡],   // id 改为 c-gangchang / c-weichang / c-neijing, link 改为 /pages/center/index?slug=xxx
  infoBanner: { ... }
};

features 改为:

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, 保留空数组以防编译错

/** @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

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
export interface Banner {
  id: string;
  title: string;
  subtitle?: string;
  image: string;
  link: string;
  sort: number;
  enabled: boolean;
}
  • Step 5.3: 写 services/expert.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
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 顶部区块
// 顶部替换 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
{/* 专家团队 */}
<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 样式
.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

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
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
export default {
  navigationBarTitleText: '中心介绍',
  navigationBarBackgroundColor: '#0a6dff',
  navigationBarTextStyle: 'white'
};
  • Step 6.4: 写 src/pages/center/index.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
@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 注册路由
// 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 硬编码

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)
// 替换 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 个文件

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 改侧栏

<!-- 删除这两行 -->
<!-- <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 函数
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:

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
const routes = {
  // ...
  centers: renderCenters,
  // ...
};
  • Step 8.7: 浏览器登录 admin, 进入"中心管理", 新增/编辑/删除 3 个中心, 验证 OK

Task 9: 端到端验证

  • Step 9.1: 重启 PM2
ssh root@124.221.67.199 'cd /path && pm2 delete yuyueguahao-api; pm2 start server/src/index.js --name yuyueguahao-api'
  • Step 9.2: 验证后端
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
# 选 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 模块代码完全从仓库删除