feat: v1.5 — plan editor, voice prompts, UI polish

Major features:
- Training plan editor: edit preset days/duration per plan (in-place
  override via custom_plans storage; preset ids preserved so existing
  records stay valid)
- Voice prompts at 4 fixed points during training (halfway / 30s /
  10s / done) via Tencent Cloud TTS (cloudfunctions/tts + utils/voice.js)
- Free-training button: switched from outline (transparent) to ghost
  variant (theme-tinted) for visual weight

Bug fixes:
- 4 functional pages: SVG data URIs failed to render in WeChat because
  '\#' in colors was parsed as a data-URI fragment delimiter. encode the
  whole SVG via encodeURIComponent in utils/icons.js build().
- Old records (saved before id field was added) could not be deleted
  (data-id was empty, triggered defensive guard). Backfill stable
  legacy-<month>-<index>-<duration> ids in getRecords() and persist.
- settings.js plan-row editor button event was bubbling up to the row's
  bindtap (which also fired onSelectPlan). Wrapped in catch:tap.

UI:
- Settings page: 3 hardcoded plans replaced with dynamic buildPlansList
  that overlays custom_plans on top of presets
- Plan editor: bottom-sheet modal in settings page (regular view, not
  ui-modal — WeChat custom component root element drops position:fixed
  in this runtime)
- Free-training button: rounded pill style, full-width, 24rpx gap from
  primary action; bottom sheet uses max-height: 88vh + internal scroll
- Version bumped to v1.5, last updated 2026-06-10

Removed:
- Daily reminder section (dailyReminder / reminderTime) — replaced by
  the 4 voice prompts which cover the same user need without requiring
  long-term scheduling that WeChat mini-programs can't actually do

Misc:
- utils/plan.js refactored to formula-driven: presets declare
  totalDays/startTarget/increment/cycleDays, days[] is generated. Same
  formula applies to custom plans.
- Timer _remind() guards each prompt on minimum duration so short
  free-mode sessions don't fire 'last30' at the start
- Cloud storage cloud_plans field added to data push payload; restored
  on first install via _restoreFromCloud
- .gitignore added for local AI tool caches (.reasonix/, reasonix.toml,
  .codegraph/daemon.pid)
This commit is contained in:
2026-06-10 17:23:13 +08:00
parent 90e0e64156
commit 1d55df72b3
54 changed files with 2710 additions and 934 deletions
+262 -39
View File
@@ -1,5 +1,44 @@
const storage = require('../../utils/storage')
const themeMod = require('../../utils/theme')
const planMod = require('../../utils/plan')
const iconsMod = require('../../utils/icons')
const cloud = require('../../utils/cloud')
/**
* Build the simplified plans list shown in the settings UI by overlaying
* user-customized plans on top of the 3 presets. Each item carries
* everything the editor needs to repopulate its form.
*/
const buildPlansList = (customPlans) => {
const presets = planMod.plans // { beginner, intermediate, advanced }
const custom = customPlans || {}
const order = ['beginner', 'intermediate', 'advanced']
return order.map((id) => {
const p = custom[id] || presets[id]
return {
id: p.id,
name: p.name,
desc: p.description,
isCustom: !!custom[id],
totalDays: p.totalDays,
startTarget: p.startTarget,
increment: p.increment,
cycleDays: p.cycleDays
}
})
}
const MAX_DAYS = 365
const MIN_DAYS = 1
const MAX_TARGET = 3600
const MIN_TARGET = 5
const MAX_INCREMENT = 300
const MAX_CYCLE = 30
const _toInt = (v, fallback) => {
const n = parseInt(v, 10)
return Number.isFinite(n) ? n : fallback
}
Page({
data: {
@@ -7,16 +46,17 @@ Page({
themeStyle: themeMod.BASE_VARS,
themes: themeMod.THEMES,
currentThemeId: 'orange',
plans: [
{ id: 'beginner', name: '初级', desc: '7天计划 · 30秒起步', days: 7 },
{ id: 'intermediate', name: '中级', desc: '14天计划 · 60秒起步', days: 14 },
{ id: 'advanced', name: '高级', desc: '30天计划 · 90秒起步', days: 30 }
],
plans: [],
currentPlanId: 'beginner',
dailyReminder: true,
reminderTime: '08:00',
voiceGuide: true,
vibrate: true
vibrate: true,
icons: iconsMod.build(),
// Editor state
showPlanEditor: false,
editingPlanId: '',
editingIsCustom: false,
editingDraft: null, // { name, totalDays, startTarget, increment, cycleDays }
editorPreview: [] // [{ day, target }, ...]
},
onLoad() {
@@ -25,58 +65,59 @@ Page({
themeMod.applyThemeToPage(this)
this.setData({
currentPlanId: s.planId,
dailyReminder: s.dailyReminder,
reminderTime: s.reminderTime,
voiceGuide: s.voiceGuide,
vibrate: s.vibrate,
currentThemeId: theme.id
currentThemeId: theme.id,
theme,
icons: iconsMod.build(theme),
plans: buildPlansList(storage.getCustomPlans())
})
},
onShow() {
try {
const tb = this.getTabBar()
if (tb) tb.setData({ selected: 2 })
if (tb) tb.setData({ selected: 3 })
} catch (e) {}
themeMod.applyThemeToPage(this)
// sync highlighted theme in case it was changed elsewhere
this.setData({ currentThemeId: themeMod.getCurrentTheme().id })
const theme = themeMod.getCurrentTheme()
this.setData({
currentThemeId: theme.id,
theme,
icons: iconsMod.build(theme),
plans: buildPlansList(storage.getCustomPlans())
})
},
onSelectTheme(e) {
const id = e.currentTarget.dataset.id
const theme = themeMod.setTheme(id)
this.setData({ currentThemeId: id })
try {
const tb = this.getTabBar()
if (tb) tb.setData({ themeStyle: themeMod.getThemeStyle(theme) })
} catch (e) {}
themeMod.applyThemeToPage(this)
},
onSelectPlan(e) {
const planId = e.currentTarget.dataset.id
const s = storage.getSettings()
s.planId = planId
storage.saveSettings(s)
this.setData({ currentPlanId: planId })
wx.showToast({ title: '计划已切换', icon: 'success', duration: 1200 })
},
if (s.planId === planId) return
onToggleReminder(e) {
const s = storage.getSettings()
s.dailyReminder = e.detail.value
storage.saveSettings(s)
this.setData({ dailyReminder: e.detail.value })
},
onReminderTimeChange(e) {
const s = storage.getSettings()
s.reminderTime = e.detail.value
storage.saveSettings(s)
this.setData({ reminderTime: e.detail.value })
wx.showModal({
title: '切换训练计划',
content: '切换计划会重置训练进度,原计划的历史记录仍会保留在训练记录中。确定继续吗?',
confirmText: '确定切换',
success: (res) => {
if (!res.confirm) return
s.planId = planId
s.planStartDate = storage.getToday()
storage.saveSettings(s)
this.setData({ currentPlanId: planId })
wx.showToast({ title: '计划已切换', icon: 'success', duration: 1200 })
}
})
},
onToggleVoice(e) {
@@ -108,14 +149,196 @@ Page({
content: '将删除所有训练记录和连续打卡数据,此操作不可恢复。确定继续吗?',
confirmText: '确定清除',
confirmColor: '#E53935',
success: async (res) => {
if (!res.confirm) return
wx.removeStorageSync('training_records')
wx.removeStorageSync('current_streak')
wx.removeStorageSync('user_settings')
wx.removeStorageSync('app_theme')
wx.removeStorageSync('custom_plans')
try { await cloud.clearAll() } catch (e) {}
wx.setStorageSync('user_settings', {
planId: 'beginner',
voiceGuide: true,
vibrate: true,
planStartDate: storage.getToday()
})
wx.setStorageSync('current_streak', { count: 0, lastDate: '' })
themeMod.setTheme('orange')
this.setData({
currentPlanId: 'beginner',
voiceGuide: true,
vibrate: true,
currentThemeId: 'orange'
})
themeMod.applyThemeToPage(this)
// Reset plans list — custom_plans are gone
this.setData({ plans: buildPlansList(storage.getCustomPlans()) })
wx.showToast({ title: '已清除', icon: 'success', duration: 1500 })
}
})
},
// -------- Plan editor --------
onTapEditPlan(e) {
// stopPropagation: prevent the row's onSelectPlan from also firing
const id = e.currentTarget.dataset.id
if (!id) return
const plan = planMod.getPlan(id, storage.getCustomPlans())
const isCustom = !!storage.getCustomPlans()[id]
const draft = {
name: plan.name,
totalDays: plan.totalDays,
startTarget: plan.startTarget,
increment: plan.increment,
cycleDays: plan.cycleDays
}
this.setData({
showPlanEditor: true,
editingPlanId: id,
editingIsCustom: isCustom,
editingDraft: draft,
editorPreview: plan.days
})
},
onCloseEditor() {
this.setData({ showPlanEditor: false })
},
// Swallow bubble for inner touches (e.g. tapping the sheet body
// shouldn't dismiss the modal — only the backdrop should).
onNoop() { /* no-op */ },
_recomputePreview(draft) {
return planMod.generatePlanDays({
totalDays: draft.totalDays,
startTarget: draft.startTarget,
increment: draft.increment,
cycleDays: draft.cycleDays
})
},
onEditName(e) {
const draft = { ...this.data.editingDraft, name: e.detail.value }
this.setData({ editingDraft: draft })
},
onEditTotalDays(e) {
const draft = {
...this.data.editingDraft,
totalDays: _toInt(e.detail.value, 0)
}
this.setData({
editingDraft: draft,
editorPreview: this._recomputePreview(draft)
})
},
onEditStartTarget(e) {
const draft = {
...this.data.editingDraft,
startTarget: _toInt(e.detail.value, 0)
}
this.setData({
editingDraft: draft,
editorPreview: this._recomputePreview(draft)
})
},
onEditIncrement(e) {
const draft = {
...this.data.editingDraft,
increment: _toInt(e.detail.value, 0)
}
this.setData({
editingDraft: draft,
editorPreview: this._recomputePreview(draft)
})
},
onEditCycleDays(e) {
const draft = {
...this.data.editingDraft,
cycleDays: _toInt(e.detail.value, 0)
}
this.setData({
editingDraft: draft,
editorPreview: this._recomputePreview(draft)
})
},
_validateDraft(draft) {
if (!draft.name || !draft.name.trim()) return '请填写计划名称'
if (draft.totalDays < MIN_DAYS || draft.totalDays > MAX_DAYS) {
return `总天数需在 ${MIN_DAYS}-${MAX_DAYS} 之间`
}
if (draft.startTarget < MIN_TARGET || draft.startTarget > MAX_TARGET) {
return `起始时长需在 ${MIN_TARGET}-${MAX_TARGET} 秒之间`
}
if (draft.increment < 0 || draft.increment > MAX_INCREMENT) {
return `每次增加需在 0-${MAX_INCREMENT} 秒之间`
}
if (draft.cycleDays < 1 || draft.cycleDays > MAX_CYCLE) {
return `每几天增加需在 1-${MAX_CYCLE} 之间`
}
return null
},
onSavePlan() {
const { editingPlanId, editingDraft } = this.data
if (!editingPlanId || !editingDraft) return
const err = this._validateDraft(editingDraft)
if (err) {
wx.showToast({ title: err, icon: 'none', duration: 1800 })
return
}
const planData = {
id: editingPlanId,
name: editingDraft.name.trim(),
totalDays: editingDraft.totalDays,
startTarget: editingDraft.startTarget,
increment: editingDraft.increment,
cycleDays: editingDraft.cycleDays,
days: this._recomputePreview(editingDraft),
description: `${editingDraft.totalDays}天计划 · ${editingDraft.startTarget}秒起步`
}
storage.saveCustomPlan(editingPlanId, planData)
this.setData({
showPlanEditor: false,
plans: buildPlansList(storage.getCustomPlans())
})
wx.showToast({ title: '已保存', icon: 'success', duration: 1200 })
},
onResetPlan() {
const { editingPlanId } = this.data
if (!editingPlanId) return
wx.showModal({
title: '恢复默认',
content: '将恢复此计划的默认设置,自定义内容会丢失。确定继续吗?',
confirmText: '确定恢复',
confirmColor: '#E53935',
success: (res) => {
if (res.confirm) {
wx.removeStorageSync('training_records')
wx.removeStorageSync('current_streak')
// also clear cloud data
try { require('../../utils/cloud').clearAll() } catch (e) {}
wx.showToast({ title: '已清除', icon: 'success', duration: 1500 })
if (!res.confirm) return
storage.resetCustomPlan(editingPlanId)
// Reopen editor pointed at the preset version
const preset = planMod.getPlan(editingPlanId)
const draft = {
name: preset.name,
totalDays: preset.totalDays,
startTarget: preset.startTarget,
increment: preset.increment,
cycleDays: preset.cycleDays
}
this.setData({
editingIsCustom: false,
editingDraft: draft,
editorPreview: preset.days,
plans: buildPlansList(storage.getCustomPlans())
})
wx.showToast({ title: '已恢复默认', icon: 'success', duration: 1200 })
}
})
}
+4 -1
View File
@@ -1,4 +1,7 @@
{
"usingComponents": {},
"usingComponents": {
"ui-card": "/components/ui-card/ui-card",
"ui-btn": "/components/ui-btn/ui-btn"
},
"navigationBarTitleText": "设置"
}
+127 -47
View File
@@ -1,8 +1,8 @@
<view class="container" style="{{themeStyle}}">
<!-- 配色方案 -->
<view class="card">
<ui-card variant="in">
<view class="section-header">
<text class="section-emoji">🎨</text>
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
<text class="section-title">配色方案</text>
</view>
<view class="theme-list">
@@ -15,15 +15,15 @@
>
<view class="theme-color-dot" style="background: {{item.primary}};"></view>
<text class="theme-name">{{item.name}}</text>
<text class="plan-check" wx:if="{{currentThemeId === item.id}}">✓</text>
<image class="theme-check-img" wx:if="{{currentThemeId === item.id}}" src="{{icons.check}}" mode="aspectFit"></image>
</view>
</view>
</view>
</ui-card>
<!-- 训练计划选择 -->
<view class="card">
<ui-card variant="in">
<view class="section-header">
<text class="section-emoji">📋</text>
<image class="section-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
<text class="section-title">训练计划</text>
</view>
<view class="plan-list">
@@ -41,82 +41,68 @@
<text class="plan-item-name">{{item.name}}</text>
<text class="plan-item-desc">{{item.desc}}</text>
</view>
<text class="plan-check" wx:if="{{currentPlanId === item.id}}">✓</text>
<view class="plan-actions">
<view class="plan-edit-btn" catch:tap="onTapEditPlan" data-id="{{item.id}}">
<image class="plan-edit-img" src="{{icons.edit}}" mode="aspectFit"></image>
</view>
<image class="plan-check-img" wx:if="{{currentPlanId === item.id}}" src="{{icons.check}}" mode="aspectFit"></image>
</view>
</view>
</view>
</view>
<!-- 提醒设置 -->
<view class="card">
<view class="section-header">
<text class="section-emoji">🔔</text>
<text class="section-title">提醒</text>
</view>
<view class="setting-row">
<view class="setting-left">
<text class="setting-icon">📅</text>
<text class="setting-label">每日提醒</text>
</view>
<switch checked="{{dailyReminder}}" bindchange="onToggleReminder" color="{{theme.primary}}"/>
</view>
<view class="setting-row" wx:if="{{dailyReminder}}">
<view class="setting-left">
<text class="setting-icon">🕐</text>
<text class="setting-label">提醒时间</text>
</view>
<picker mode="time" value="{{reminderTime}}" bindchange="onReminderTimeChange">
<text class="setting-value">{{reminderTime}}</text>
</picker>
</view>
</view>
</ui-card>
<!-- 训练偏好 -->
<view class="card">
<ui-card variant="in">
<view class="section-header">
<text class="section-emoji"></text>
<image class="section-icon" src="{{icons.settingsFill}}" mode="aspectFit"></image>
<text class="section-title">训练偏好</text>
</view>
<view class="setting-row">
<view class="setting-left">
<text class="setting-icon">🔊</text>
<image class="setting-icon" src="{{icons.voiceFill}}" mode="aspectFit"></image>
<text class="setting-label">语音引导</text>
</view>
<switch checked="{{voiceGuide}}" bindchange="onToggleVoice" color="{{theme.primary}}"/>
</view>
<view class="setting-row">
<view class="setting-left">
<text class="setting-icon">📳</text>
<image class="setting-icon" src="{{icons.notificationFill}}" mode="aspectFit"></image>
<text class="setting-label">振动反馈</text>
</view>
<switch checked="{{vibrate}}" bindchange="onToggleVibrate" color="{{theme.primary}}"/>
</view>
</view>
</ui-card>
<!-- 数据管理 -->
<view class="card">
<ui-card variant="in">
<view class="section-header">
<text class="section-emoji">🗑</text>
<image class="section-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
<text class="section-title">数据管理</text>
</view>
<button class="btn-danger" bindtap="onClearData">
<text>清除所有训练记录</text>
</button>
</view>
<ui-btn
variant="danger"
size="md"
block
text="清除所有训练记录"
icon-src="{{icons.delete}}"
bindtap="onClearData"
></ui-btn>
</ui-card>
<!-- 关于 -->
<view class="card about-card">
<ui-card variant="in">
<view class="section-header">
<text class="section-emoji"></text>
<image class="section-icon" src="{{icons.infoFill}}" mode="aspectFit"></image>
<text class="section-title">关于</text>
</view>
<view class="about-list">
<view class="about-row">
<text class="about-label">版本</text>
<text class="about-value">v1.2.0</text>
<text class="about-value">v1.5</text>
</view>
<view class="about-row">
<text class="about-label">更新日期</text>
<text class="about-value">2026-06-04</text>
<text class="about-value">2026-06-10</text>
</view>
<view class="about-row" bindtap="onCopyWechat">
<text class="about-label">开发者</text>
@@ -126,5 +112,99 @@
<view class="about-footer">
<text class="about-copy">Made with ❤️ for better fitness</text>
</view>
</ui-card>
<!-- 训练计划编辑器:用普通 view 不用 ui-modal,
因为 WeChat 自定义组件根元素上 position: fixed 会被吃,导致 body 被推到容器末尾。
普通 view 的 position: fixed 在小程序里 100% 生效。 -->
<view wx:if="{{showPlanEditor}}" class="plan-editor-mask" catchtouchmove="onNoop">
<view class="plan-editor-mask__bg" bindtap="onCloseEditor"></view>
<view class="plan-editor-sheet" catchtap="onNoop">
<view class="plan-editor-handle"></view>
<view class="editor-title">编辑训练计划</view>
<view class="editor-field">
<text class="editor-label">计划名称</text>
<input
class="editor-input"
type="text"
value="{{editingDraft.name}}"
bindinput="onEditName"
placeholder="例如:初级"
maxlength="20"
/>
</view>
<view class="editor-field">
<text class="editor-label">总天数</text>
<input
class="editor-input"
type="number"
value="{{editingDraft.totalDays}}"
bindinput="onEditTotalDays"
placeholder="1-365"
/>
</view>
<view class="editor-row">
<view class="editor-field-half">
<text class="editor-label">起始时长(秒)</text>
<input
class="editor-input"
type="number"
value="{{editingDraft.startTarget}}"
bindinput="onEditStartTarget"
placeholder="5-3600"
/>
</view>
<view class="editor-field-half">
<text class="editor-label">每次增加(秒)</text>
<input
class="editor-input"
type="number"
value="{{editingDraft.increment}}"
bindinput="onEditIncrement"
placeholder="0-300"
/>
</view>
</view>
<view class="editor-field">
<text class="editor-label">每几天增加</text>
<input
class="editor-input"
type="number"
value="{{editingDraft.cycleDays}}"
bindinput="onEditCycleDays"
placeholder="1-30"
/>
</view>
<view class="editor-preview">
<view class="editor-preview-head">
<text class="editor-preview-title">预览</text>
<text class="editor-preview-meta">共 {{editorPreview.length}} 天</text>
</view>
<view class="editor-preview-list">
<view class="editor-preview-row" wx:for="{{editorPreview}}" wx:key="day">
<text class="editor-preview-day">第 {{item.day}} 天</text>
<text class="editor-preview-target">{{item.target}} 秒</text>
</view>
<view class="editor-preview-empty" wx:if="{{editorPreview.length === 0}}">
<text>请填写总天数</text>
</view>
</view>
</view>
<view class="editor-actions">
<view class="editor-action-reset" wx:if="{{editingIsCustom}}" bindtap="onResetPlan">
<text>恢复默认</text>
</view>
<view class="editor-action-save" bindtap="onSavePlan">
<text>保存</text>
</view>
</view>
<text class="editor-cancel" bindtap="onCloseEditor">取消</text>
</view>
</view>
</view>
+255 -33
View File
@@ -4,10 +4,10 @@
margin-bottom: 20rpx;
}
.section-emoji {
font-size: 28rpx;
.section-icon {
width: 32rpx;
height: 32rpx;
margin-right: 10rpx;
line-height: 1;
}
.section-header .section-title {
@@ -15,9 +15,7 @@
}
/* ---- theme picker ---- */
.theme-list {
margin-top: 0;
}
.theme-list { margin-top: 0; }
.theme-item {
display: flex;
@@ -27,9 +25,7 @@
transition: background 0.2s ease;
}
.theme-item:last-child {
border-bottom: none;
}
.theme-item:last-child { border-bottom: none; }
.theme-item.active {
background: var(--primary-bg);
@@ -64,11 +60,14 @@
color: var(--text);
}
/* ---- plan list ---- */
.plan-list {
margin-top: 0;
.theme-check-img {
width: 32rpx;
height: 32rpx;
}
/* ---- plan list ---- */
.plan-list { margin-top: 0; }
.plan-item {
display: flex;
align-items: center;
@@ -76,9 +75,7 @@
border-bottom: 1rpx solid var(--border);
}
.plan-item:last-child {
border-bottom: none;
}
.plan-item:last-child { border-bottom: none; }
.plan-item.active {
background: var(--primary-bg);
@@ -132,10 +129,41 @@
margin-top: 4rpx;
}
.plan-check {
color: var(--primary);
font-size: 32rpx;
font-weight: 700;
.plan-check-img {
width: 32rpx;
height: 32rpx;
}
.plan-actions {
display: flex;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
}
.plan-edit-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: transparent;
transition: background 0.15s ease;
}
.plan-edit-btn:active {
background: rgba(var(--primary-rgb), 0.12);
}
.plan-edit-img {
width: 36rpx;
height: 36rpx;
opacity: 0.55;
}
.plan-item.active .plan-edit-img {
opacity: 0.85;
}
/* ---- setting rows ---- */
@@ -147,9 +175,7 @@
border-bottom: 1rpx solid var(--border);
}
.setting-row:last-child {
border-bottom: none;
}
.setting-row:last-child { border-bottom: none; }
.setting-left {
display: flex;
@@ -157,7 +183,8 @@
}
.setting-icon {
font-size: 28rpx;
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
}
@@ -172,13 +199,7 @@
}
/* ---- about ---- */
.about-card {
margin-top: 8rpx;
}
.about-list {
margin-top: 12rpx;
}
.about-list { margin-top: 12rpx; }
.about-row {
display: flex;
@@ -188,9 +209,7 @@
border-bottom: 1rpx solid var(--border);
}
.about-row:last-child {
border-bottom: none;
}
.about-row:last-child { border-bottom: none; }
.about-label {
font-size: 26rpx;
@@ -218,3 +237,206 @@
font-size: 22rpx;
color: #CCCCCC;
}
/* ---- plan editor modal ---- */
/* 普通 view 实现的底部 sheet,不用 ui-modal 是因为 WeChat 自定义组件根元素
上 position: fixed 会被运行时吞掉,导致 body 落到容器末尾(非浮层)。 */
.plan-editor-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.plan-editor-mask__bg {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.45);
animation: planEditorFade 0.25s ease;
}
.plan-editor-sheet {
position: relative;
width: 100%;
max-height: 88vh;
overflow-y: auto;
box-sizing: border-box;
background: #FFFFFF;
border-radius: 32rpx 32rpx 0 0;
padding: 12rpx 32rpx 40rpx;
/* Sheet is z-index 1000 above the tab bar (z-index 999), but the tab bar
still visually covers the bottom of the sheet. Add the tab bar's
height (110rpx + safe area) to padding-bottom so "保存/取消" sit
clearly above the tab bar. */
padding-bottom: calc(150rpx + env(safe-area-inset-bottom));
animation: planEditorSlideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
.plan-editor-handle {
width: 64rpx;
height: 8rpx;
background: #DDD;
border-radius: 4rpx;
margin: 0 auto 16rpx;
}
@keyframes planEditorFade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes planEditorSlideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.editor-title {
display: block;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: var(--text);
margin-bottom: 20rpx;
}
.editor-field {
display: flex;
flex-direction: column;
margin-bottom: 14rpx;
}
.editor-row {
display: flex;
gap: 12rpx;
}
.editor-row .editor-field-half {
flex: 1;
}
.editor-label {
font-size: 22rpx;
color: var(--text-secondary);
margin-bottom: 6rpx;
line-height: 1;
}
.editor-input {
height: 68rpx;
background: #F5F5F5;
border-radius: 10rpx;
padding: 0 16rpx;
font-size: 26rpx;
color: var(--text);
box-sizing: border-box;
width: 100%;
}
.editor-preview {
margin-top: 6rpx;
background: #FAFAFA;
border-radius: 14rpx;
padding: 12rpx 16rpx;
}
.editor-preview-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8rpx;
}
.editor-preview-title {
font-size: 22rpx;
font-weight: 600;
color: var(--text);
}
.editor-preview-meta {
font-size: 20rpx;
color: var(--text-secondary);
}
.editor-preview-list {
/* No fixed max-height: the modal body scrolls, preview expands inline. */
min-height: 60rpx;
}
.editor-preview-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8rpx 0;
border-bottom: 1rpx solid #EEE;
font-size: 24rpx;
}
.editor-preview-row:last-child {
border-bottom: none;
}
.editor-preview-day {
color: var(--text);
}
.editor-preview-target {
color: var(--primary);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.editor-preview-empty {
text-align: center;
color: var(--text-secondary);
font-size: 22rpx;
padding: 16rpx 0;
}
.editor-actions {
display: flex;
gap: 12rpx;
margin-top: 20rpx;
}
.editor-action-reset,
.editor-action-save {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: 600;
transition: opacity 0.15s ease, background 0.15s ease;
}
.editor-action-reset {
background: #FFFFFF;
color: var(--text-secondary);
border: 2rpx solid var(--border);
}
.editor-action-reset:active {
background: #F5F5F5;
}
.editor-action-save {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba(var(--primary-rgb), 0.25);
}
.editor-action-save:active {
opacity: 0.9;
}
.editor-cancel {
display: block;
text-align: center;
font-size: 26rpx;
color: var(--text-secondary);
padding: 12rpx 0 0;
}