Prompt file imported from lxKylin/yucheng-mini (
.codex/prompts/M4.prompt.md). Fill in{{target_name}}before use. Copyright stays with the author.
M4 · 提醒列表模块开发
任务概述
你是一名熟练的 Taro 4 + React 18 + TypeScript + Zustand 专家。请按照以下规范,逐步完成 M4 提醒列表模块的全部开发内容。完成每个子任务后自行验证,确认无误再进入下一个。
当前项目状态
- M1 基础架构已完成:全局 SCSS 变量(
src/styles/variables.scss)、全局基础样式(src/app.scss)、AppBar组件已实现 - M2 数据层已完成:
src/types/index.ts、src/utils/dateUtils.ts、src/store/reminderStore.ts、src/hooks/useReminders.ts(含useDerivedList、useReminderActions)均已实现 - M3 首页已完成:以下组件已可直接复用
src/components/StatusTag/index.tsxsrc/components/ProgressBar/index.tsxsrc/components/MedicineCard/index.tsx(接收item、showActions、onDone、onDetailprops)
- M5 已完成:
src/components/BottomSheet/index.tsx:接收open、title、onClose、onAfterOpen、onAfterClose、childrenprops,使用 taroifyPopup实现底部弹层src/components/ReminderForm/index.tsx:表单组件,支持新增(reminderId不传)和编辑(reminderId传入)两种模式,接收reminderId?、onSuccess、onCancelprops
- M6 已完成:
src/components/ReminderDetail/index.tsx:接收reminderId、onClose、onEditprops
- 本模块需完整实现(当前均为占位内容):
src/pages/list/index.tsxsrc/pages/list/index.scss
src/pages/list/index.config.ts已正确配置,不修改
AppBar 组件接口说明
interface AppBarProps {
title: string;
caption?: string;
right?: ReactNode; // 右侧插槽,传入自定义按钮节点
}
BottomSheet 组件接口说明
interface BottomSheetProps {
open: boolean;
title: string;
onClose: () => void;
onAfterOpen?: () => void; // 弹层入场动画完成后触发
onAfterClose?: () => void; // 弹层退场动画完成后触发
children: ReactNode;
}
taroify 组件使用规范
项目已安装 @taroify/core,且通过 vite-plugin-style-import 配置了样式自动导入(引入组件时无需手动 import CSS 文件)。
本模块不引入新的 taroify 组件。搜索框使用 Taro 原生 Input 组件(小程序兼容性最佳);筛选标签为自定义 pill 按钮。已有组件(MedicineCard、BottomSheet 等)内部已使用的 taroify 组件无需重复处理。
UI 设计参考
UI 参考 ui5.0/index.html 中 data-screen="list" 区域及 renderList() 函数逻辑:
列表页结构:
AppBar(提醒列表 + 统一管理全部开药任务 + 右侧 "+" 按钮)
↓
SearchBar(搜索框:圆角,左侧搜索图标,输入药物名称或规格实时过滤)
↓
FilterTabs(筛选标签横向滚动:全部N / 逾期·今日N / 7天内N / 正常N / 暂停N)
→ 选中标签:主色背景 + 白色文字(pill 形)
→ 未选中:浅色背景 + 深色文字
↓
MedicineCard 列表(MedicineCard 组件复用,含"已开药"和"查看详情"操作按钮)
→ 有结果:按优先级排序的药物卡片列表
→ 无结果:空态提示(搜索无结果 vs 暂无提醒)
↓
DetailBottomSheet(提醒详情,内含 ReminderDetail 组件)
FormBottomSheet(新增/编辑表单,内含 ReminderForm 组件)
筛选逻辑(参照 ui5.0 renderList()):
// filter 值与 DerivedReminder.level 直接对应
const byFilter = filter === 'all' || item.level === filter;
// 搜索同时匹配 name 和 spec,不区分大小写
const bySearch =
!term ||
item.name.toLowerCase().includes(term) ||
item.spec.toLowerCase().includes(term);
// 两个条件同时生效
return byFilter && bySearch;
筛选数量计算(从全量数据计算,不受搜索词影响):
const counts = {
all: allItems.length,
danger: allItems.filter((r) => r.level === 'danger').length,
warning: allItems.filter((r) => r.level === 'warning').length,
good: allItems.filter((r) => r.level === 'good').length,
paused: allItems.filter((r) => r.level === 'paused').length
};
子任务 1:实现列表页(src/pages/list/index.tsx)
完整替换 src/pages/list/index.tsx:
import { useEffect, useMemo, useRef, useState } from 'react'
import { Input, Text, View } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import AppBar from '@/components/AppBar'
import BottomSheet from '@/components/BottomSheet'
import MedicineCard from '@/components/MedicineCard'
import ReminderDetail from '@/components/ReminderDetail'
import ReminderForm from '@/components/ReminderForm'
import { useDerivedList, useReminderActions } from '@/hooks/useReminders'
import './index.scss'
type FilterKey = 'all' | 'danger' | 'warning' | 'good' | 'paused'
type SheetMode = 'detail' | 'form'
const FILTER_TABS: { key: FilterKey; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'danger', label: '逾期·今日' },
{ key: 'warning', label: '7天内' },
{ key: 'good', label: '正常' },
{ key: 'paused', label: '暂停' },
]
export default function ListPage() {
const [searchTerm, setSearchTerm] = useState('')
const [activeFilter, setActiveFilter] = useState<FilterKey>('all')
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>('form')
const [detailId, setDetailId] = useState('')
const [editReminderId, setEditReminderId] = useState<string | undefined>(undefined)
const [formKey, setFormKey] = useState(0)
const sheetOpenRef = useRef(sheetOpen)
const allItems = useDerivedList()
const { markDone } = useReminderActions()
const formOpen = sheetOpen && sheetMode === 'form'
const detailOpen = sheetOpen && sheetMode === 'detail'
// 筛选标签数量(从全量数据计算,不受搜索词影响)
const counts = useMemo<Record<FilterKey, number>>(
() => ({
all: allItems.length,
danger: allItems.filter(r => r.level === 'danger').length,
warning: allItems.filter(r => r.level === 'warning').length,
good: allItems.filter(r => r.level === 'good').length,
paused: allItems.filter(r => r.level === 'paused').length,
}),
[allItems],
)
// 经过搜索 + 筛选双重过滤的列表
const filteredItems = useMemo(() => {
const term = searchTerm.trim().toLowerCase()
return allItems.filter(item => {
const byFilter = activeFilter === 'all' || item.level === activeFilter
const bySearch =
!term ||
item.name.toLowerCase().includes(term) ||
item.spec.toLowerCase().includes(term)
return byFilter && bySearch
})
}, [allItems, searchTerm, activeFilter])
useEffect(() => {
sheetOpenRef.current = sheetOpen
}, [sheetOpen])
// 页面卸载时恢复 TabBar(防止异常退出时 TabBar 被隐藏)
useEffect(() => {
return () => {
Taro.showTabBar({ animation: false })
}
}, [])
const handleSheetEntered = () => {
Taro.hideTabBar({ animation: true })
}
const handleSheetExited = () => {
// 若是 detail→form 的模式切换,sheetOpen 仍为 true,不恢复 TabBar
if (sheetOpenRef.current) return
setDetailId('')
setEditReminderId(undefined)
Taro.showTabBar({ animation: true })
}
const closeSheet = () => setSheetOpen(false)
const handleAddNew = () => {
Taro.hideTabBar({ animation: true })
setEditReminderId(undefined)
setFormKey(k => k + 1)
setSheetMode('form')
setSheetOpen(true)
}
const handleDetail = (id: string) => {
setDetailId(id)
Taro.hideTabBar({ animation: true })
setSheetMode('detail')
setSheetOpen(true)
}
const handleEditFromDetail = (id: string) => {
setEditReminderId(id)
setFormKey(k => k + 1)
setSheetMode('form')
setSheetOpen(true)
}
const handleMarkDone = (id: string) => {
const target = allItems.find(item => item.id === id)
if (!target) return
Taro.showModal({
title: '确认已开药',
content: `确认已完成「{{target_name}}」本次开药吗?系统会更新最近开药日期并推算下一次提醒。`,
confirmText: '确认',
cancelText: '取消',
confirmColor: '#157a66',
success: res => {
if (!res.confirm) return
markDone(id)
Taro.showToast({ title: '已进入下一轮', icon: 'success', duration: 1500 })
},
})
}
useLoad(() => {
console.log('list page loaded')
})
return (
<View className='list-page'>
{/* 顶部标题栏,右侧"+"新增按钮 */}
<AppBar
title='提醒列表'
caption='统一管理全部开药任务'
right={
<View className='list-add-btn' onClick={handleAddNew} role='button' aria-label='新增提醒'>
<Text className='list-add-btn__icon'>+</Text>
</View>
}
/>
{/* 搜索框 */}
<View className='list-search'>
<View className='list-search__icon' aria-hidden='true'>
<Text className='list-search__icon-text'>⌕</Text>
</View>
<Input
className='list-search__input'
value={searchTerm}
placeholder='搜索药物名称或规格'
placeholderClass='list-search__placeholder'
onInput={e => setSearchTerm(e.detail.value)}
/>
</View>
{/* 筛选标签横向滚动区 */}
<View className='list-filters'>
{FILTER_TABS.map(({ key, label }) => (
<View
key={key}
className={`list-filter${activeFilter === key ? ' list-filter--active' : ''}`}
onClick={() => setActiveFilter(key)}
>
<Text className='list-filter__text'>
{label} {counts[key]}
</Text>
</View>
))}
</View>
{/* 卡片列表 / 空态 */}
<View className='list-content'>
{filteredItems.length > 0 ? (
filteredItems.map(item => (
<MedicineCard
key={item.id}
item={item}
showActions
onDone={() => handleMarkDone(item.id)}
onDetail={() => handleDetail(item.id)}
/>
))
) : (
<View className='list-empty'>
<Text className='list-empty__icon'>💊</Text>
<Text className='list-empty__text'>
{searchTerm.trim() ? '没有符合条件的提醒' : '暂无提醒,点击右上角 + 添加'}
</Text>
</View>
)}
</View>
{/* 提醒详情 BottomSheet */}
<BottomSheet
open={detailOpen}
title='提醒详情'
onClose={closeSheet}
onAfterOpen={handleSheetEntered}
onAfterClose={handleSheetExited}
>
{detailId ? (
<ReminderDetail reminderId={detailId} onClose={closeSheet} onEdit={handleEditFromDetail} />
) : null}
</BottomSheet>
{/* 新增/编辑 BottomSheet */}
<BottomSheet
open={formOpen}
title={editReminderId ? '编辑提醒' : '新增提醒'}
onClose={closeSheet}
onAfterOpen={handleSheetEntered}
onAfterClose={handleSheetExited}
>
<ReminderForm
key={formKey}
reminderId={editReminderId}
onSuccess={closeSheet}
onCancel={closeSheet}
/>
</BottomSheet>
</View>
)
}
子任务 2:实现列表页样式(src/pages/list/index.scss)
完整替换 src/pages/list/index.scss:
@use '@/assets/styles/variables' as *;
.list-page {
min-height: 100vh;
background: $bg;
padding: $spacing-4;
// 为原生 TabBar 预留底部空间
padding-bottom: 140px;
}
// ─── AppBar 右侧 "+" 按钮 ──────────────────────────────────────
.list-add-btn {
width: 76px;
height: 76px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 24px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid $line-soft;
box-shadow: $shadow-card;
&__icon {
color: $ink;
font-size: 32px;
font-weight: 300;
line-height: 1;
}
}
// ─── 搜索框 ───────────────────────────────────────────────────
.list-search {
display: flex;
align-items: center;
gap: $spacing-3;
min-height: 76px;
padding: 0 $spacing-5;
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid $line-soft;
box-shadow: $shadow-card;
margin-top: $spacing-3;
&__icon {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
&__icon-text {
color: $quiet;
font-size: 28px;
line-height: 1;
}
&__input {
flex: 1;
color: $ink;
font-size: $text-base;
background: transparent;
border: none;
outline: none;
// Taro Input 需要重置默认样式
min-height: 44px;
line-height: 44px;
}
&__placeholder {
color: $quiet;
}
}
// ─── 筛选标签 ─────────────────────────────────────────────────
.list-filters {
display: flex;
flex-wrap: nowrap;
gap: $spacing-2;
overflow-x: auto;
margin-top: $spacing-3;
// 隐藏横向滚动条
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
}
.list-filter {
flex-shrink: 0;
min-height: 56px;
display: inline-flex;
align-items: center;
padding: 0 $spacing-5;
border-radius: $radius-pill;
background: rgba(255, 255, 255, 0.92);
border: 1px solid $line-soft;
cursor: pointer;
&__text {
color: $muted;
font-size: $text-sm;
font-weight: 800;
white-space: nowrap;
}
// 选中状态:主色背景 + 白色文字
&--active {
background: $primary;
border-color: $primary;
.list-filter__text {
color: #fff;
}
}
}
// ─── 卡片列表 ─────────────────────────────────────────────────
.list-content {
display: grid;
gap: $spacing-3;
margin-top: $spacing-3;
}
// ─── 空态 ─────────────────────────────────────────────────────
.list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $spacing-3;
min-height: 320px;
border-radius: $radius-lg;
background: rgba(255, 255, 255, 0.72);
border: 1px dashed $line;
&__icon {
font-size: 64px;
opacity: 0.5;
}
&__text {
color: $muted;
font-size: $text-base;
font-weight: 600;
text-align: center;
}
}
验收检查项
完成以上两个子任务后,请逐项自查:
-
pnpm dev:weapp编译无报错 - 列表页正确显示所有提醒,按优先级排序(与
deriveAll排序一致) - 搜索框实时过滤(药物名称 + 规格,大小写不敏感),输入即生效
- 5 个筛选标签各自数量准确,与实际数据一致
- 筛选切换后列表即时更新,选中标签高亮
- 搜索 + 筛选可同时生效(两个条件取交集)
- 无结果时显示空态;搜索无结果与暂无提醒的文案有所区分
- 点击右上角 "+" 按钮可打开新增表单 BottomSheet,TabBar 随之隐藏
- 点击卡片"查看详情"可打开详情 BottomSheet,再点"编辑提醒"可切换为编辑表单
- 点击"已开药"弹出确认弹窗,确认后列表刷新,Toast 显示"已进入下一轮"
- BottomSheet 关闭后 TabBar 恢复显示
- 新增提醒后,数据立即出现在列表中(Zustand 响应式更新)
- 编辑提醒后,原条目数据正确更新