42 KiB
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.jsserver/src/seeds/centers.js
修改 (server)
server/src/index.js— mountcenterRouter+expertAdminRouter, unmountdoctorRouter+departmentRouter, 移除 seedDoctors/seedDepartmentsserver/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 centersserver/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/expertssrc/pages/home/index.module.scss— 新增bannerSwiper/bannerImg/bannerDot样式src/pages/appointment/index.tsx— DEPARTMENT_OPTIONS 硬编码 2 项, 删 departmentListsrc/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' })}>更多 >></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 个流程
- 首页顶部显示 Swiper 轮播
- 首页 3 中心卡 (肛肠/胃肠/内镜) 点击 → 跳 center 页
- center 页显示 name/intro/highlights/description, 底部"立即预约本中心"跳 appointment
- 专家区域从 /api/v1/experts 拉取, admin 改后小程序刷新即生效
- 预约页"就诊科室"硬编码 2 选项
- admin "专家资源" 列表/编辑/删除正常
- 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 模块代码完全从仓库删除