Prompt file imported from lxKylin/yucheng-mini (
.codex/prompts/M5.prompt.md). Fill in{{nextDate}},{{remindDate}},{{values_time}}before use. Copyright stays with the author.
M5 · 新增/编辑提醒模块开发
任务概述
你是一名熟练的 Taro 4 + React 18 + TypeScript + Zustand 专家。请按照以下规范,逐步完成 M5 新增/编辑提醒模块的全部开发内容。完成每个子任务后自行验证,确认无误再进入下一个。
当前项目状态
- M1 基础架构已完成:全局 SCSS 变量(
src/styles/variables.scss)、全局基础样式(src/app.scss) - M2 数据层已完成:
src/types/index.ts、src/utils/dateUtils.ts(含calcNextDate、calcRemindDate、today、addDays等工具函数)、src/store/reminderStore.ts(含addReminder、updateReminder)、src/hooks/useReminders.ts(含useReminderActions、useDerivedById) - M3 首页已完成:
src/pages/home/index.tsx已实现,其中handleAddNew以 Toast "功能开发中" 占位,本模块需替换为打开 BottomSheet - 以下组件需在本模块完整实现:
src/components/BottomSheet/index.tsx(目前为空骨架:return <View className="bottom-sheet">{/* M6 开发时填充 */}</View>)src/components/BottomSheet/index.scss(目前为空)src/components/ReminderForm/index.tsx(目录为空,需新建文件)src/components/ReminderForm/index.scss(目录为空,需新建文件)
src/pages/home/index.config.ts已正确配置,不修改- M6(详情面板)尚未开发,"查看详情"按钮保持 Toast 占位不变
taroify 组件使用规范
项目已安装 @taroify/core,且通过 vite-plugin-style-import 配置了样式自动导入(引入组件时无需手动 import CSS 文件)。
本模块适用的 taroify 组件:
| 用途 | taroify 组件 | import 路径 | 说明 |
|---|---|---|---|
| 底部弹层 | Popup |
@taroify/core/popup |
placement="bottom" 实现底部弹出,className 覆盖圆角和背景 |
| 操作按钮 | Button |
@taroify/core/button |
提交按钮,className 覆盖颜色匹配设计规范 |
表单输入字段使用 Taro 原生组件(小程序兼容性最佳):
| 用途 | 组件 | import 路径 |
|---|---|---|
| 文字输入 | Input |
@tarojs/components |
| 多行文字 | Textarea |
@tarojs/components |
| 日期/时间选择 | Picker |
@tarojs/components |
⚠️ 小程序 Picker 说明:微信小程序不支持
<input type="date">和<input type="time">,必须使用 TaroPicker组件。mode="date"时onChange事件签名为(e: { detail: { value: string } }) => void,返回"YYYY-MM-DD"字符串;mode="time"时返回"HH:mm"字符串。
UI 设计参考
UI 参考 ui5.0/index.html 中 form() 函数生成的 Sheet 内容:
Sheet 整体结构:
BottomSheet(底部弹层,顶部圆角,半透明遮罩)
├── 标题栏("新增提醒" 或 "编辑提醒" + 右侧关闭按钮)
└── ReminderForm(表单内容区)
├── 提示卡(绿色软色背景:"保存后自动推算下次开药")
├── 药物名称(必填输入框,placeholder: "例如:阿托伐他汀")
├── 药物规格(选填输入框,placeholder: "例如:20mg · 每晚一次")
├── [最近开药日期 Picker] [提醒时间 Picker](两列布局)
├── 下次开药间隔快捷按钮:7天 / 14天 / 30天 / 60天(选中高亮为主色)
├── 提前提醒量快捷按钮:1天 / 3天 / 7天 / 14天(选中高亮为主色)
├── 备注(多行文本框,placeholder: "医院、复诊事项、注意事项")
├── CalcBar(蓝绿软色背景,实时显示:预计下次开药日期为 YYYY-MM-DD,提醒时间为 MM-DD HH:mm)
└── 提交按钮(pill 形,主色填充:"保存并开启提醒" 或 "保存修改")
子任务 1:实现 BottomSheet 组件(使用 taroify Popup)
使用 taroify Popup 提供底部弹出动画和遮罩层,通过 className 覆盖为项目设计规范。BottomSheet 将在 M5(新增/编辑)和 M6(详情)中复用,设计为纯容器组件,自身不包含任何业务逻辑。
完整替换 src/components/BottomSheet/index.tsx:
import type { ReactNode } from 'react'
import { View, Text } from '@tarojs/components'
import Popup from '@taroify/core/popup'
import './index.scss'
interface BottomSheetProps {
/** 控制弹层显示/隐藏 */
open: boolean
/** 标题文字 */
title: string
/** 关闭回调(点击遮罩或关闭按钮时触发) */
onClose: () => void
/** 弹层内容 */
children: ReactNode
}
export default function BottomSheet({ open, title, onClose, children }: BottomSheetProps) {
return (
<Popup
open={open}
placement='bottom'
className='bottom-sheet-panel'
onClose={onClose}
>
<View className='bottom-sheet__head'>
<Text className='bottom-sheet__title'>{title}</Text>
<View
className='bottom-sheet__close'
role='button'
aria-label='关闭'
onClick={onClose}
>
<Text className='bottom-sheet__close-icon'>×</Text>
</View>
</View>
<View className='bottom-sheet__body'>{children}</View>
</Popup>
)
}
完整替换 src/components/BottomSheet/index.scss:
@use '@/assets/styles/variables' as *;
// taroify Popup 底部弹层样式覆盖
// placement="bottom" 时,面板 class 为 .taroify-popup
// 使用 .bottom-sheet-panel 组合选择器提升优先级
.bottom-sheet-panel {
&.taroify-popup {
border-radius: $radius-strong $radius-strong 0 0 !important;
background: $paper !important;
max-height: 88vh;
overflow-y: auto;
box-shadow: 0 -20px 60px rgba(10, 30, 35, 0.16);
}
}
.bottom-sheet {
// ─── 标题栏 ────────────────────────────────────────────────────
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-3;
padding: $spacing-6 $spacing-8 $spacing-4;
// 粘性定位,防止长表单时标题被滚动隐藏
position: sticky;
top: 0;
background: $paper;
z-index: 1;
}
&__title {
color: $ink-strong;
font-size: $text-xl;
font-weight: 900;
line-height: 1.2;
}
&__close {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $surface-soft;
flex-shrink: 0;
}
&__close-icon {
color: $muted;
font-size: $text-xl;
line-height: 1;
}
// ─── 内容区 ────────────────────────────────────────────────────
&__body {
padding: 0 $spacing-8 $spacing-9;
}
}
子任务 2:实现 ReminderForm 组件
ReminderForm 是纯表单内容组件,内嵌到 BottomSheet 中。
- 新增模式:
reminderId不传,表单以默认值初始化,提交后调用addReminder - 编辑模式:
reminderId传入已有记录 ID,表单回填已有数据,提交后调用updateReminder
2.1 新建 src/components/ReminderForm/index.tsx
import { useState, useEffect, useMemo } from 'react'
import { View, Text, Input, Textarea, Picker } from '@tarojs/components'
import Taro from '@tarojs/taro'
import Button from '@taroify/core/button'
import { useReminderActions, useDerivedById } from '@/hooks/useReminders'
import { calcNextDate, calcRemindDate, today } from '@/utils/dateUtils'
import {
DEFAULT_REMIND_TIME,
DEFAULT_INTERVAL,
DEFAULT_BEFORE,
} from '@/constants'
import './index.scss'
interface ReminderFormProps {
/** 编辑模式时传入 reminderId;不传或传空字符串则为新增模式 */
reminderId?: string
/** 保存成功后回调 */
onSuccess: () => void
/** 取消/关闭回调 */
onCancel: () => void
}
interface FormValues {
name: string
spec: string
lastDate: string // YYYY-MM-DD
time: string // HH:mm
interval: number
before: number
note: string
}
// 快捷按钮选项(与 UI5.0 一致)
const INTERVAL_QUICK = [7, 14, 30, 60] as const
const BEFORE_QUICK = [1, 3, 7, 14] as const
function makeDefaults(): FormValues {
return {
name: '',
spec: '',
lastDate: today(),
time: DEFAULT_REMIND_TIME,
interval: DEFAULT_INTERVAL,
before: DEFAULT_BEFORE,
note: '',
}
}
export default function ReminderForm({
reminderId,
onSuccess,
onCancel,
}: ReminderFormProps) {
const isEdit = Boolean(reminderId)
// useDerivedById 接受字符串,编辑模式传 id,新增模式传 '' 返回 null
const existingItem = useDerivedById(reminderId ?? '')
const { addReminder, updateReminder } = useReminderActions()
const [values, setValues] = useState<FormValues>(makeDefaults)
// 编辑模式:当 reminderId 变化时回填数据;新增模式:重置为默认值
useEffect(() => {
if (isEdit && existingItem) {
setValues({
name: existingItem.name,
spec: existingItem.spec,
lastDate: existingItem.lastDate,
time: existingItem.time,
interval: existingItem.interval,
before: existingItem.before,
note: existingItem.note,
})
} else if (!isEdit) {
setValues(makeDefaults())
}
}, [reminderId]) // eslint-disable-line react-hooks/exhaustive-deps
// 实时预算文案
const calcText = useMemo(() => {
const nextDate = calcNextDate(values.lastDate, values.interval)
const remindDate = calcRemindDate(nextDate, values.before)
return `预计下次开药日期为 {{nextDate}},提醒时间为 {{remindDate}} {{values_time}}`
}, [values.lastDate, values.interval, values.before, values.time])
const set = (field: keyof FormValues, val: string | number) => {
setValues((prev) => ({ ...prev, [field]: val }))
}
const handleSubmit = () => {
if (!values.name.trim()) {
Taro.showToast({ title: '请填写药物名称', icon: 'none', duration: 1500 })
return
}
if (values.interval < 1 || values.interval > 365) {
Taro.showToast({ title: '间隔天数需在 1-365 之间', icon: 'none', duration: 1500 })
return
}
const payload = {
name: values.name.trim(),
spec: values.spec.trim(),
lastDate: values.lastDate,
time: values.time,
interval: values.interval,
before: values.before,
note: values.note.trim(),
}
if (isEdit && reminderId) {
updateReminder(reminderId, payload)
Taro.showToast({ title: '提醒已更新', icon: 'success', duration: 1500 })
} else {
addReminder({
...payload,
status: 'active',
history: [values.lastDate],
})
Taro.showToast({ title: '提醒已创建', icon: 'success', duration: 1500 })
}
onSuccess()
}
return (
<View className='reminder-form'>
{/* 提示卡 */}
<View className='reminder-form__notice'>
<Text className='reminder-form__notice-title'>保存后自动推算下次开药</Text>
<Text className='reminder-form__notice-desc'>
系统会使用最近开药日期、间隔天数和提前提醒量生成下一次提醒。
</Text>
</View>
{/* 药物名称 */}
<View className='reminder-form__field'>
<Text className='reminder-form__label'>药物名称 必填</Text>
<Input
className='reminder-form__input'
value={values.name}
placeholder='例如:阿托伐他汀'
onInput={(e) => set('name', e.detail.value)}
/>
</View>
{/* 药物规格 */}
<View className='reminder-form__field'>
<Text className='reminder-form__label'>药物规格</Text>
<Input
className='reminder-form__input'
value={values.spec}
placeholder='例如:20mg · 每晚一次'
onInput={(e) => set('spec', e.detail.value)}
/>
</View>
{/* 最近开药日期 + 提醒时间(两列) */}
<View className='reminder-form__grid'>
<View className='reminder-form__field'>
<Text className='reminder-form__label'>最近开药日期</Text>
<Picker
mode='date'
value={values.lastDate}
onChange={(e) => set('lastDate', e.detail.value)}
>
<View className='reminder-form__picker'>
<Text className='reminder-form__picker-text'>{values.lastDate}</Text>
</View>
</Picker>
</View>
<View className='reminder-form__field'>
<Text className='reminder-form__label'>提醒时间</Text>
<Picker
mode='time'
value={values.time}
onChange={(e) => set('time', e.detail.value)}
>
<View className='reminder-form__picker'>
<Text className='reminder-form__picker-text'>{values.time}</Text>
</View>
</Picker>
</View>
</View>
{/* 下次开药间隔快捷按钮 */}
<View className='reminder-form__field'>
<Text className='reminder-form__label'>下次开药间隔</Text>
<View className='reminder-form__quick'>
{INTERVAL_QUICK.map((val) => (
<View
key={val}
className={`reminder-form__quick-btn${values.interval === val ? ' reminder-form__quick-btn--active' : ''}`}
onClick={() => set('interval', val)}
>
<Text>{val}天</Text>
</View>
))}
</View>
</View>
{/* 提前提醒量快捷按钮 */}
<View className='reminder-form__field'>
<Text className='reminder-form__label'>提前提醒量</Text>
<View className='reminder-form__quick'>
{BEFORE_QUICK.map((val) => (
<View
key={val}
className={`reminder-form__quick-btn${values.before === val ? ' reminder-form__quick-btn--active' : ''}`}
onClick={() => set('before', val)}
>
<Text>{val}天</Text>
</View>
))}
</View>
</View>
{/* 备注 */}
<View className='reminder-form__field'>
<Text className='reminder-form__label'>备注</Text>
<Textarea
className='reminder-form__textarea'
value={values.note}
placeholder='医院、复诊事项、注意事项'
onInput={(e) => set('note', e.detail.value)}
/>
</View>
{/* CalcBar 实时预算 */}
<View className='reminder-form__calc'>
<Text>{calcText}</Text>
</View>
{/* 提交按钮(taroify Button,className 覆盖样式) */}
<Button
className='reminder-form__submit'
color='primary'
onClick={handleSubmit}
>
{isEdit ? '保存修改' : '保存并开启提醒'}
</Button>
</View>
)
}
2.2 新建 src/components/ReminderForm/index.scss
@use '@/assets/styles/variables' as *;
.reminder-form {
display: grid;
gap: $spacing-4;
// ─── 提示卡 ────────────────────────────────────────────────────
&__notice {
padding: $spacing-4;
border-radius: $radius;
background: $primary-soft;
border: 1px solid rgba(21, 122, 102, 0.12);
}
&__notice-title {
display: block;
color: $primary-deep;
font-size: $text-sm;
font-weight: 900;
}
&__notice-desc {
display: block;
margin-top: 4px;
color: $primary;
font-size: $text-xs;
line-height: 1.6;
}
// ─── 字段容器 ──────────────────────────────────────────────────
&__field {
display: grid;
gap: $spacing-2;
}
&__label {
color: #385750;
font-size: $text-sm;
font-weight: 900;
}
// ─── 文字输入框 ────────────────────────────────────────────────
&__input {
width: 100%;
min-height: 52px;
padding: 0 $spacing-4;
border: 1px solid $line-strong;
border-radius: $radius;
color: $ink;
background: #fff;
font-size: $text-base;
display: flex;
align-items: center;
}
// ─── 多行文本框 ────────────────────────────────────────────────
&__textarea {
width: 100%;
min-height: 80px;
padding: $spacing-3 $spacing-4;
border: 1px solid $line-strong;
border-radius: $radius;
color: $ink;
background: #fff;
font-size: $text-base;
}
// ─── Picker 展示区(点击触发系统 Picker) ─────────────────────
&__picker {
min-height: 52px;
padding: 0 $spacing-4;
border: 1px solid $line-strong;
border-radius: $radius;
background: #fff;
display: flex;
align-items: center;
}
&__picker-text {
color: $ink;
font-size: $text-base;
}
// ─── 两列布局 ──────────────────────────────────────────────────
&__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-3;
}
// ─── 快捷按钮组 ────────────────────────────────────────────────
&__quick {
display: flex;
gap: $spacing-2;
flex-wrap: wrap;
}
&__quick-btn {
min-height: 40px;
padding: 0 $spacing-4;
border-radius: $radius-pill;
color: $primary-deep;
background: $primary-soft;
font-size: $text-sm;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
// 选中状态:主色填充 + 白色文字
&--active {
color: #fff;
background: $primary;
}
}
// ─── CalcBar 实时预算提示 ──────────────────────────────────────
&__calc {
padding: $spacing-4;
border-radius: $radius;
background: $primary-soft;
color: $primary-deep;
font-size: $text-sm;
font-weight: 800;
line-height: 1.65;
}
// ─── 提交按钮(覆盖 taroify Button 默认样式) ──────────────────
&__submit {
width: 100% !important;
min-height: 52px !important;
border-radius: $radius-pill !important;
font-size: $text-base !important;
font-weight: 900 !important;
background: $primary !important;
color: #fff !important;
border: none !important;
margin-top: $spacing-2;
}
}
子任务 3:更新首页,接入新增表单
修改 src/pages/home/index.tsx,将 FAB 的占位 Toast 替换为打开 BottomSheet。
本任务只修改以下三处,不重写整个文件。
3.1 替换 import 区域
将文件顶部的:
import { Text, View } from '@tarojs/components';
import Taro, { useLoad } from '@tarojs/taro';
import AppBar from '@/components/AppBar';
import MedicineCard from '@/components/MedicineCard';
import { useDerivedList, useReminderActions } from '@/hooks/useReminders';
import { formatDisplay, parseDate } from '@/utils/dateUtils';
替换为:
import { useState } from 'react';
import { 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 ReminderForm from '@/components/ReminderForm';
import { useDerivedList, useReminderActions } from '@/hooks/useReminders';
import { formatDisplay, parseDate } from '@/utils/dateUtils';
3.2 在 Home 函数内添加 BottomSheet 状态
在 const allItems = useDerivedList() 这一行之前,添加:
const [formOpen, setFormOpen] = useState(false);
// formKey 变化时 ReminderForm 重新挂载,实现新增模式的表单重置
const [formKey, setFormKey] = useState(0);
3.3 替换 handleAddNew 函数
将:
// TODO: M5 接入后替换为打开新增 BottomSheet。
const handleAddNew = () => {
Taro.showToast({ title: '功能开发中', icon: 'none', duration: 1200 });
};
替换为:
const handleAddNew = () => {
setFormKey((k) => k + 1); // 每次打开自增,触发 ReminderForm 重新挂载以重置状态
setFormOpen(true);
};
3.4 在 return JSX 末尾添加 BottomSheet
在最后的 </View> 结束标签(home-page 根容器的结束标签)之前,即现有 FAB <View className="home-fab" ...> 之后,添加:
{
/* 新增提醒 BottomSheet(M5) */
}
<BottomSheet
open={formOpen}
title="新增提醒"
onClose={() => setFormOpen(false)}
>
<ReminderForm
key={formKey}
onSuccess={() => setFormOpen(false)}
onCancel={() => setFormOpen(false)}
/>
</BottomSheet>;
子任务 4:验证
pnpm dev:weapp 启动后在微信开发者工具中验证:
- 在首页点击右下角 FAB(
+按钮),BottomSheet 从底部滑入 - Sheet 标题显示"新增提醒",关闭按钮可关闭
- 表单默认值:日期=今天、时间=09:00、间隔=30天(30天快捷按钮高亮)、提前量=3天(3天快捷按钮高亮)
- 点击最近开药日期区域,弹出系统日期选择器,选择后 UI 更新
- 点击提醒时间区域,弹出系统时间选择器,选择后 UI 更新
- 切换快捷按钮(如 7天 / 14天),CalcBar 预算文案实时变化
- 不填药物名称点提交 → Toast "请填写药物名称",Sheet 不关闭
- 填写完整后提交 → Toast "提醒已创建",Sheet 关闭,首页数据更新
- 再次点 FAB → 表单已重置(名称为空,默认值恢复)
验收检查清单
- BottomSheet 组件:接收
open、title、onClose、children,使用 taroify Popup 渲染底部弹层 - BottomSheet 动画:从底部滑入,点击遮罩或 × 按钮均可关闭
- BottomSheet 圆角:顶部两角圆润(
$radius-strong = 44px),符合 UI5.0 设计稿 - ReminderForm 组件:表单渲染完整,包含药物名称、规格、日期、时间、间隔、提前量、备注、CalcBar、提交按钮共 8 个区域
- 默认值:新增模式下日期=今天、时间=09:00、间隔=30(快捷按钮高亮)、提前=3(快捷按钮高亮)
- 日期 Picker:点击后弹出原生日期选择器,选择后
lastDate更新,CalcBar 随之变化 - 时间 Picker:点击后弹出原生时间选择器,选择后
time更新,CalcBar 随之变化 - 快捷按钮高亮:当前选中值对应的按钮显示为主色填充+白色文字,其余为浅绿色背景+深绿文字
- CalcBar 实时更新:lastDate / interval / before / time 任意变化,CalcBar 立即重新计算展示
- 必填校验:药物名称为空时点提交,Toast 提示"请填写药物名称",Sheet 不关闭
- 新增成功:填写完整提交,Toast "提醒已创建",Sheet 关闭,首页列表出现新条目
- 表单重置:关闭 Sheet 后再次打开,表单内容已恢复为默认值(通过
keyprop 实现) - 编辑模式预备:
reminderId传入时,表单回填已有数据(M6 接入时验证) - 首页 FAB 联通:点击 FAB → BottomSheet 弹出 → 展示 ReminderForm
-
pnpm dev:weapp无 TypeScript / SCSS 编译报错
