feat: redesign habit detail paper flow
This commit is contained in:
@@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ArchiveRestore, Check, ChevronRight, Download, GripVertical, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||
import { downloadFullBackup, preflightBackup, restoreBackup, uploadJson, requestJson, type BackupMode, type BackupPreflight } from './api'
|
||||
import { mergeReorderedSubset, moveItemWithinScope } from './lib/task-utils'
|
||||
import { archivePanelFlags, changedHabitFields, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils'
|
||||
import { archivePanelFlags, changedHabitFields, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitDetailDate, formatHabitDetailProgress, formatHabitHistoryNumber, formatHabitRecordMode, formatHabitSchedule, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||
import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state'
|
||||
@@ -53,6 +53,12 @@ const habitComposerOpen = ref(false)
|
||||
const editingHabit = ref<Habit | null>(null)
|
||||
const originalHabitForm = ref<HabitFormValues | null>(null)
|
||||
const selectedHabit = ref<Habit | null>(null)
|
||||
const selectedHabitDetailProgress = computed(() => {
|
||||
const habit = selectedHabit.value
|
||||
return habit
|
||||
? formatHabitDetailProgress({ ...habit, value: logFor(habit, todayKey.value)?.value })
|
||||
: null
|
||||
})
|
||||
const habitHistory = ref<HabitLog[]>([])
|
||||
const habitHistoryLoading = ref(false)
|
||||
const habitHistoryLoadingMore = ref(false)
|
||||
@@ -499,6 +505,7 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
|
||||
void loadHabitHistory(true)
|
||||
}
|
||||
function closeHabitDetail() {
|
||||
if (busy.value) return
|
||||
habitHistoryRequest += 1
|
||||
selectedHabit.value = null
|
||||
habitHistory.value = []
|
||||
@@ -524,7 +531,8 @@ async function archiveHabit(h: Habit) {
|
||||
})
|
||||
}
|
||||
async function restoreHabit(h: Habit) {
|
||||
if (!h.archived_at) return
|
||||
if (!h.archived_at || busy.value) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await performHabitRestore({
|
||||
@@ -540,10 +548,15 @@ async function restoreHabit(h: Habit) {
|
||||
void nextTick(() => habitArchiveToggle.value?.focus())
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '恢复失败'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function deleteHabit(h: Habit) {
|
||||
if (!h.archived_at || !(await confirmAction(`永久删除习惯“${h.name}”?`, '所有历史打卡记录也会被删除,且无法恢复。'))) return
|
||||
if (!h.archived_at || busy.value) return
|
||||
if (!(await confirmAction(`永久删除习惯“${h.name}”?`, '所有历史打卡记录也会被删除,且无法恢复。'))) return
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
|
||||
@@ -553,6 +566,8 @@ async function deleteHabit(h: Habit) {
|
||||
void nextTick(() => habitArchiveToggle.value?.focus())
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '永久删除失败'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function loadArchivedHabits() {
|
||||
@@ -814,9 +829,24 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
<AppSheet :open="Boolean(selectedHabit)" variant="detail" panel-class="habit-detail-sheet" title-id="habit-detail-title" initial-focus="button[aria-label='关闭习惯详情']" :busy="busy" @close="closeHabitDetail">
|
||||
<template v-if="selectedHabit">
|
||||
<header class="app-sheet__header"><div><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
|
||||
<div class="app-sheet__body">
|
||||
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
|
||||
<p v-if="selectedHabit.archived_at" class="habit-detail-archive-note" role="status">此习惯已归档,历史记录仍完整保留。</p>
|
||||
<header class="app-sheet__header habit-detail-header"><div><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" :disabled="busy" @click="closeHabitDetail"><X/></button></header>
|
||||
<div class="app-sheet__body habit-detail-body">
|
||||
<template v-if="selectedHabitDetailProgress">
|
||||
<section class="habit-detail-hero" aria-labelledby="habit-detail-today-label">
|
||||
<strong>{{ selectedHabitDetailProgress.primary }}</strong>
|
||||
<small>{{ selectedHabitDetailProgress.note }}</small>
|
||||
</section>
|
||||
<div class="habit-detail-progress-row">
|
||||
<div><span id="habit-detail-today-label">今日进度</span><small>按今日记录计算</small></div>
|
||||
<strong>{{ selectedHabitDetailProgress.semantic }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
<dl class="habit-detail-meta">
|
||||
<div class="habit-detail-meta-row"><dt>记录方式</dt><dd>{{ formatHabitRecordMode(selectedHabit) }}</dd></div>
|
||||
<div class="habit-detail-meta-row"><dt>计划</dt><dd>{{ formatHabitSchedule(selectedHabit) }}</dd></div>
|
||||
<div class="habit-detail-meta-row"><dt>开始日期</dt><dd>{{ formatHabitDetailDate(selectedHabit.start_date) }}</dd></div>
|
||||
</dl>
|
||||
<section class="habit-history" aria-labelledby="habit-history-title" :aria-busy="habitHistoryLoading">
|
||||
<header class="habit-history__header"><h4 id="habit-history-title">历史记录</h4><small v-if="habitHistory.length">{{ habitHistory.length }} 条</small></header>
|
||||
<div v-if="habitHistoryLoading" class="habit-history__loading" role="status"><span v-for="index in 3" :key="index"></span><b class="sr-only">正在加载历史记录</b></div>
|
||||
@@ -834,8 +864,8 @@ onBeforeUnmount(() => {
|
||||
<small v-else-if="habitHistory.length && !habitHistoryLoading" class="habit-history__end">没有更早记录了</small>
|
||||
</section>
|
||||
</div>
|
||||
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer>
|
||||
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="soft-button habit-restore-button" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer>
|
||||
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer habit-detail-active-actions"><button type="button" class="habit-detail-archive-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button><button type="button" class="soft-button habit-detail-edit-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button></footer>
|
||||
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger habit-detail-archived-actions"><button type="button" class="danger-text habit-delete-button" :disabled="busy" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button><button type="button" class="soft-button habit-restore-button" :disabled="busy" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button></footer>
|
||||
</template>
|
||||
</AppSheet>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, dayBefore, defaultView, fabBottomReserved, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, mergeHabitHistory, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, snapFabPosition, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
||||
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, dayBefore, defaultView, fabBottomReserved, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitDetailDate, formatHabitDetailProgress, formatHabitHistoryNumber, formatHabitRecordMode, formatHabitSchedule, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, mergeHabitHistory, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, snapFabPosition, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
||||
|
||||
describe('MVP view utilities', () => {
|
||||
it('formats a local date as YYYY-MM-DD', () => {
|
||||
@@ -90,6 +90,24 @@ describe('MVP view utilities', () => {
|
||||
expect(formatHabitHistoryNumber(1.25)).toBe('1.25')
|
||||
})
|
||||
|
||||
it('formats paper-flow habit details from real habit fields without invented data', () => {
|
||||
expect(formatHabitRecordMode({ kind: 'boolean' })).toBe('完成 / 未完成')
|
||||
expect(formatHabitRecordMode({ kind: 'numeric', target: 100, unit: '个' })).toBe('按数量记录 · 目标 100 个')
|
||||
expect(formatHabitSchedule({ schedule_type: 'daily' })).toBe('每天')
|
||||
expect(formatHabitSchedule({ schedule_type: 'weekly', weekdays: [0, 2, 6] })).toBe('每周一、周三、周日')
|
||||
expect(formatHabitSchedule({ schedule_type: 'monthly', month_days: [1, 15, 31] })).toBe('每月 1、15、31 日')
|
||||
expect(formatHabitSchedule({ schedule_type: 'interval', interval_days: 3 })).toBe('每 3 天')
|
||||
expect(formatHabitSchedule({ schedule_type: 'weekly', weekdays: null })).toBe('每周(日期未设置)')
|
||||
expect(formatHabitDetailDate('2026-08-12')).toBe('2026年8月12日')
|
||||
expect(formatHabitDetailDate(undefined)).toBe('未提供')
|
||||
expect(formatHabitDetailProgress({ kind: 'numeric', target: 100, value: 68 })).toEqual({ primary: '68 / 100', note: '今天还差 32', semantic: '68%' })
|
||||
expect(formatHabitDetailProgress({ kind: 'numeric', target: 3, value: 4 })).toEqual({ primary: '4 / 3', note: '今日目标已达成', semantic: '已达标' })
|
||||
expect(formatHabitDetailProgress({ kind: 'boolean', value: false })).toEqual({ primary: '未完成', note: '今天尚未完成', semantic: '未完成' })
|
||||
expect(formatHabitDetailProgress({ kind: 'boolean', value: true })).toEqual({ primary: '已完成', note: '今日目标已达成', semantic: '已完成' })
|
||||
expect(formatHabitDetailProgress({ kind: 'numeric', target: 100, archived_at: '2026-09-01T00:00:00Z' })).toBeNull()
|
||||
expect(formatHabitDetailProgress({ kind: 'boolean', archived_at: '2026-09-01T00:00:00Z' })).toBeNull()
|
||||
})
|
||||
|
||||
it('only completes numeric habits when progress reaches the target', () => {
|
||||
expect(isHabitComplete('numeric', 3, 5)).toBe(false)
|
||||
expect(isHabitComplete('numeric', 5, 5)).toBe(true)
|
||||
|
||||
@@ -107,6 +107,63 @@ export function formatHabitHistoryNumber(value: number) {
|
||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6)))
|
||||
}
|
||||
|
||||
type HabitDetailFields = {
|
||||
kind?: string
|
||||
target?: number
|
||||
unit?: string
|
||||
archived_at?: string | null
|
||||
schedule_type?: 'daily' | 'weekly' | 'monthly' | 'interval'
|
||||
weekdays?: number[] | null
|
||||
month_days?: number[] | null
|
||||
interval_days?: number | null
|
||||
}
|
||||
|
||||
export function formatHabitRecordMode(habit: HabitDetailFields) {
|
||||
if (habit.kind !== 'numeric') return '完成 / 未完成'
|
||||
const unit = habit.unit ? ` ${habit.unit}` : ''
|
||||
return `按数量记录 · 目标 ${formatHabitHistoryNumber(habit.target ?? 1)}${unit}`
|
||||
}
|
||||
|
||||
export function formatHabitSchedule(habit: HabitDetailFields) {
|
||||
if (habit.schedule_type === 'weekly') {
|
||||
const weekdays = habit.weekdays ?? []
|
||||
if (!weekdays.length) return '每周(日期未设置)'
|
||||
const labels = ['一', '二', '三', '四', '五', '六', '日']
|
||||
return `每${weekdays.map((day) => `周${labels[day] ?? day}`).join('、')}`
|
||||
}
|
||||
if (habit.schedule_type === 'monthly') {
|
||||
const monthDays = habit.month_days ?? []
|
||||
return monthDays.length ? `每月 ${monthDays.join('、')} 日` : '每月(日期未设置)'
|
||||
}
|
||||
if (habit.schedule_type === 'interval') {
|
||||
return habit.interval_days ? `每 ${habit.interval_days} 天` : '按间隔(天数未设置)'
|
||||
}
|
||||
return '每天'
|
||||
}
|
||||
|
||||
export function formatHabitDetailDate(day?: string) {
|
||||
if (!day) return '未提供'
|
||||
const [year, month, date] = day.split('-').map(Number)
|
||||
if (!year || !month || !date) return day
|
||||
return `${year}年${month}月${date}日`
|
||||
}
|
||||
|
||||
export function formatHabitDetailProgress(habit: Pick<HabitDetailFields, 'kind' | 'target' | 'unit' | 'archived_at'> & { value?: number | boolean }) {
|
||||
if (habit.archived_at) return null
|
||||
const target = habit.target ?? 1
|
||||
const value = Number(habit.value ?? 0)
|
||||
if (habit.kind !== 'numeric') {
|
||||
const complete = Boolean(habit.value)
|
||||
return { primary: complete ? '已完成' : '未完成', note: complete ? '今日目标已达成' : '今天尚未完成', semantic: complete ? '已完成' : '未完成' }
|
||||
}
|
||||
const complete = value >= target
|
||||
return {
|
||||
primary: `${formatHabitHistoryNumber(value)} / ${formatHabitHistoryNumber(target)}`,
|
||||
note: complete ? '今日目标已达成' : `今天还差 ${formatHabitHistoryNumber(Math.max(0, target - value))}${habit.unit ? ` ${habit.unit}` : ''}`,
|
||||
semantic: complete ? '已达标' : `${Math.round(target > 0 ? value / target * 100 : 0)}%`,
|
||||
}
|
||||
}
|
||||
|
||||
type HabitSchedule = {
|
||||
kind?: string
|
||||
cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value?: number | boolean }>
|
||||
|
||||
@@ -227,3 +227,8 @@ main:has(>.mvp-view .settings-sections) .settings-data>.inline-error{margin:14px
|
||||
.task-detail-notes,.task-detail-subtasks{padding-bottom:18px;border-bottom:1px solid #e4dbcf}.task-detail-notes{gap:8px}.task-detail-subtasks{display:grid;gap:4px}.task-detail-subtasks .field-label{min-height:44px}.subtask-detail,.after-completion-fields,.repeat-custom-fields{width:100%;max-width:100%;min-width:0;border:0;border-radius:0;box-shadow:none;background:transparent;overflow:visible}.subtask-detail{padding:0;border-bottom:1px solid #eee6db}.subtask-detail>span{min-width:0;flex:1;overflow-wrap:anywhere;word-break:break-word}.subtask-remove{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;padding:0;border:0;background:transparent;color:var(--muted);border-radius:9px}.subtask-remove:hover:not(:disabled){color:var(--danger);background:#fff3ef}.after-completion-fields,.repeat-custom-fields{padding:10px 0;border-top:1px solid #eee6db;border-bottom:1px solid #eee6db}.more-settings{border-bottom:1px solid #e4dbcf;padding-bottom:12px}.more-settings>summary{min-height:44px;display:flex;align-items:center;font-size:13px;font-weight:700;cursor:pointer}.more-settings-body{display:grid;gap:12px;padding-top:7px}.more-settings-body select{width:100%;min-height:44px;border-radius:10px}
|
||||
.detail-actions{position:sticky;bottom:0;z-index:3;min-height:69px;flex:0 0 auto;padding:12px 18px;display:flex;align-items:center;justify-content:space-between;gap:10px;border-top:1px solid #e4dbcf;background:#fffdf8}.detail-actions>button{min-height:44px}.detail-trash{padding:0;color:var(--danger)}.detail-save{min-width:88px}
|
||||
@media(max-width:930px){.detail{position:relative;z-index:auto;left:auto;right:auto;top:auto;bottom:auto;width:100%;max-height:min(88dvh,760px);overflow:hidden;border:1px solid var(--line);border-bottom:0;border-radius:20px 20px 0 0;box-shadow:0 -14px 38px rgba(56,40,24,.2);transform:none;transition:none;padding-bottom:0}.detail-head{position:relative;top:auto;z-index:2;height:58px;flex:0 0 58px;padding:0 18px}.detail-head .icon{width:44px;height:44px;background:transparent;color:var(--text-secondary);border-radius:10px}.detail-form{flex:1 1 auto;min-height:0;padding:18px;gap:18px;overflow-y:auto;overflow-x:hidden}.task-detail-date-time{grid-template-columns:minmax(0,1fr)}.task-detail-date-control .task-compose-date-clear,.task-detail-time-control .task-compose-time-remove{min-width:44px;min-height:44px;width:44px;height:44px}.detail-actions{width:100%;padding:12px 18px calc(12px + env(safe-area-inset-bottom))}.detail-actions>button{min-height:44px}}
|
||||
|
||||
/* Approved habit detail 01: paper flow. */
|
||||
.habit-detail-sheet{width:min(500px,100%);max-height:min(88dvh,760px);padding:0;gap:0;display:flex;flex-direction:column;background:#fffdf8;overflow:hidden}.habit-detail-archive-note{order:-1;flex:0 0 auto;margin:0;padding:10px 20px;border:0;border-bottom:1px solid #e8b7aa;background:#fff5f2;color:var(--danger);font-size:12px;line-height:1.5}.habit-detail-header{height:64px;flex:0 0 64px;padding:0 20px}.habit-detail-header h3{font-size:18px}.habit-detail-body{flex:1 1 auto;gap:0;padding:18px 20px;overflow-y:auto;overflow-x:hidden}.habit-detail-hero{padding:4px 0 18px;border-bottom:1px solid #e4dbcf}.habit-detail-hero strong{display:block;color:var(--text-primary);font-size:28px;line-height:1.15;letter-spacing:-.04em;font-variant-numeric:tabular-nums}.habit-detail-hero small{display:block;margin-top:6px;color:var(--muted);font-size:12px}.habit-detail-progress-row{min-height:66px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px;border-bottom:1px solid #e4dbcf}.habit-detail-progress-row span{font-size:12px;font-weight:700}.habit-detail-progress-row small{display:block;margin-top:3px;color:var(--muted);font-size:11px}.habit-detail-progress-row strong{color:var(--success);font-size:20px}.habit-detail-meta{margin:0 0 14px}.habit-detail-meta-row{min-height:52px;display:grid;grid-template-columns:86px minmax(0,1fr);align-items:center;gap:12px;border-bottom:1px solid #e4dbcf;font-size:13px}.habit-detail-meta-row dt{color:var(--muted);font-size:11px;font-weight:700}.habit-detail-meta-row dd{min-width:0;margin:0;text-align:right;overflow-wrap:anywhere}.habit-history__header{min-height:42px;border-bottom:1px solid #e4dbcf}.habit-history__list{border-top:0}.habit-detail-active-actions{min-height:68px;justify-content:space-between;padding:12px 20px calc(12px + env(safe-area-inset-bottom))}.habit-detail-archive-button{min-height:40px;display:inline-flex;align-items:center;gap:6px;padding:0;border:0;background:transparent;color:var(--danger);font-size:13px;font-weight:700}.habit-detail-archive-button svg{width:16px;height:16px}.habit-detail-edit-button{min-width:100px}.habit-detail-archived-actions{min-height:68px;padding:12px 20px calc(12px + env(safe-area-inset-bottom));background:#fffdf8;border-top:1px solid #e4dbcf}.habit-detail-archived-actions .habit-delete-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:0;background:transparent;color:var(--danger)}
|
||||
@media(max-width:930px){.habit-detail-sheet.app-sheet--detail{width:100%;max-width:100%}.habit-detail-sheet :is(.habit-detail-archive-button,.habit-detail-edit-button,.habit-restore-button,.habit-delete-button,.habit-history__more){min-height:44px}}
|
||||
@media(max-width:390px){.habit-detail-body{padding:16px}.habit-detail-header{padding:0 16px}.habit-detail-active-actions,.habit-detail-archived-actions{padding-left:16px;padding-right:16px}.habit-detail-meta-row{grid-template-columns:78px minmax(0,1fr)}}
|
||||
|
||||
@@ -617,7 +617,7 @@ describe('mobile sheet contract', () => {
|
||||
})
|
||||
|
||||
it('keeps the danger zone reserved for archived habit deletion', () => {
|
||||
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger"')
|
||||
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger habit-detail-archived-actions"')
|
||||
expect(mvpPanel).toContain('deleteHabit(selectedHabit)')
|
||||
expect(css).toContain('.app-sheet__danger{border-top:1px solid #f1d4cd;')
|
||||
})
|
||||
@@ -643,6 +643,8 @@ describe('mobile list row language', () => {
|
||||
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
|
||||
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
|
||||
expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"')
|
||||
expect(mvpPanel).toContain("if (!(await confirmAction(`永久删除习惯“${h.name}”?`, '所有历史打卡记录也会被删除,且无法恢复。'))) return")
|
||||
expect(mvpPanel).toContain('if (busy.value) return\n busy.value = true')
|
||||
expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })")
|
||||
expect(mvpPanel).toContain("request(`/habits/${habit.id}/logs?from=${window.from}&to=${window.to}`)")
|
||||
expect(mvpPanel).toContain('aria-labelledby="habit-history-title"')
|
||||
@@ -1230,6 +1232,51 @@ describe('settings data tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('habit detail paper-flow redesign', () => {
|
||||
it('keeps the approved information order and every existing detail state/action', () => {
|
||||
const sheet = mvpPanel.slice(mvpPanel.indexOf('<AppSheet :open="Boolean(selectedHabit)"'), mvpPanel.indexOf('</AppSheet>', mvpPanel.indexOf('<AppSheet :open="Boolean(selectedHabit)"')))
|
||||
const detail = sheet.slice(sheet.indexOf('<template v-if="selectedHabit">'))
|
||||
const ordered = [
|
||||
'habit-detail-archive-note',
|
||||
'habit-detail-title',
|
||||
'habit-detail-hero',
|
||||
'habit-detail-progress-row',
|
||||
'记录方式',
|
||||
'计划',
|
||||
'开始日期',
|
||||
'habit-history-title',
|
||||
].map((token) => detail.indexOf(token))
|
||||
expect(ordered.every((position) => position >= 0)).toBe(true)
|
||||
expect(ordered).toEqual([...ordered].sort((a, b) => a - b))
|
||||
expect(detail).toContain('selectedHabitDetailProgress.primary')
|
||||
expect(detail).toContain('selectedHabitDetailProgress.note')
|
||||
expect(detail).toContain('formatHabitRecordMode(selectedHabit)')
|
||||
expect(detail).toContain('formatHabitSchedule(selectedHabit)')
|
||||
expect(detail).toContain('formatHabitDetailDate(selectedHabit.start_date)')
|
||||
expect(detail).not.toContain('最后记录于')
|
||||
expect(detail).toContain('habitHistoryLoading')
|
||||
expect(detail).toContain('habitHistoryError')
|
||||
expect(detail).toContain('loadHabitHistory(true)')
|
||||
expect(detail).toContain('loadHabitHistory(false)')
|
||||
expect(detail).toContain('@click="editHabit(selectedHabit)"')
|
||||
expect(detail).toContain('@click="archiveHabit(selectedHabit)"')
|
||||
expect(detail).toContain('@click="restoreHabit(selectedHabit)"')
|
||||
expect(detail).toContain('@click="deleteHabit(selectedHabit)"')
|
||||
})
|
||||
|
||||
it('matches the paper-flow surface and action hierarchy at desktop and touch widths', () => {
|
||||
expect(css).toContain('/* Approved habit detail 01: paper flow. */')
|
||||
expect(css).toMatch(/\.habit-detail-hero\{[^}]*padding:[^;}]*;[^}]*border-bottom:1px solid #e4dbcf/)
|
||||
expect(css).toMatch(/\.habit-detail-hero strong\{[^}]*font-size:28px/)
|
||||
expect(css).toMatch(/\.habit-detail-progress-row\{[^}]*min-height:66px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
|
||||
expect(css).toMatch(/\.habit-detail-meta-row\{[^}]*min-height:52px;[^}]*grid-template-columns:86px minmax\(0,1fr\)/)
|
||||
expect(css).toMatch(/\.habit-detail-active-actions\{[^}]*justify-content:space-between/)
|
||||
expect(css).toMatch(/\.habit-detail-archive-button\{[^}]*background:transparent;[^}]*color:var\(--danger\)/)
|
||||
expect(css).toMatch(/@media\(max-width:930px\)\{[^}]*\.habit-detail-sheet\.app-sheet--detail\{[^}]*width:100%/)
|
||||
expect(css).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.habit-detail-sheet :is\([^}]*\)\{[^}]*min-height:44px/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('task detail layout', () => {
|
||||
it('keeps long subtask text inside the detail panel', () => {
|
||||
expect(css).toContain('.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);')
|
||||
|
||||
Reference in New Issue
Block a user