feat: refine archived habit management
This commit is contained in:
+59
-16
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||
import { Activity, ArchiveRestore, Check, ChevronRight, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||
import { moveItemWithinScope } from './lib/task-utils'
|
||||
import { changedHabitFields, dateKey, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils'
|
||||
import { archivePanelFlags, changedHabitFields, dateKey, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse } from './lib/completion-motion'
|
||||
|
||||
@@ -19,7 +19,10 @@ const emit = defineEmits<{
|
||||
const habits = ref<Habit[]>([])
|
||||
const archivedHabits = ref<Habit[]>([])
|
||||
const showArchivedHabits = ref(false)
|
||||
const archivedHabitsLoaded = ref(false)
|
||||
const archiveState = ref<ArchivePanelState>('idle')
|
||||
const archiveError = ref('')
|
||||
const archiveFlags = computed(() => archivePanelFlags(archiveState.value, archivedHabits.value.length))
|
||||
const habitArchiveToggle = ref<HTMLButtonElement | null>(null)
|
||||
const sessions = ref<Session[]>([])
|
||||
const audit = ref<any[]>([])
|
||||
const busy = ref(false)
|
||||
@@ -387,7 +390,10 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
|
||||
}
|
||||
function closeHabitDetail() {
|
||||
selectedHabit.value = null
|
||||
void nextTick(() => habitDetailOpener?.focus())
|
||||
void nextTick(() => {
|
||||
if (habitDetailOpener?.isConnected) habitDetailOpener.focus()
|
||||
else habitArchiveToggle.value?.focus()
|
||||
})
|
||||
}
|
||||
defineExpose({ openHabitComposer })
|
||||
async function archiveHabit(h: Habit) {
|
||||
@@ -400,22 +406,53 @@ async function archiveHabit(h: Habit) {
|
||||
emit('notice', '习惯已归档')
|
||||
})
|
||||
}
|
||||
async function restoreHabit(h: Habit) {
|
||||
if (!h.archived_at) return
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await performHabitRestore({
|
||||
restore: () => request(`/habits/${h.id}/restore`, { method: 'POST' }),
|
||||
commitRestore: () => {
|
||||
archivedHabits.value = archivedHabits.value.filter((item) => item.id !== h.id)
|
||||
selectedHabit.value = null
|
||||
invalidateHabitGridCache()
|
||||
},
|
||||
refreshGrid: () => loadHabits(),
|
||||
})
|
||||
emit('notice', result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试')
|
||||
void nextTick(() => habitArchiveToggle.value?.focus())
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '恢复失败'
|
||||
}
|
||||
}
|
||||
async function deleteHabit(h: Habit) {
|
||||
if (!h.archived_at || !confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
|
||||
await safe(async () => {
|
||||
error.value = ''
|
||||
try {
|
||||
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
|
||||
archivedHabits.value = archivedHabits.value.filter((item) => item.id !== h.id)
|
||||
selectedHabit.value = null
|
||||
await loadArchivedHabits()
|
||||
emit('notice', '习惯已永久删除')
|
||||
})
|
||||
void nextTick(() => habitArchiveToggle.value?.focus())
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '永久删除失败'
|
||||
}
|
||||
}
|
||||
async function loadArchivedHabits() {
|
||||
archivedHabits.value = await request('/habits?archived=true') as Habit[]
|
||||
archivedHabitsLoaded.value = true
|
||||
if (props.view !== 'habits' || archiveState.value === 'loading') return
|
||||
archiveState.value = 'loading'
|
||||
archiveError.value = ''
|
||||
try {
|
||||
archivedHabits.value = await request('/habits?archived=true') as Habit[]
|
||||
archiveState.value = 'success'
|
||||
} catch (reason) {
|
||||
archiveState.value = 'error'
|
||||
archiveError.value = reason instanceof Error ? reason.message : '归档习惯加载失败'
|
||||
}
|
||||
}
|
||||
async function toggleArchivedHabits() {
|
||||
showArchivedHabits.value = !showArchivedHabits.value
|
||||
if (showArchivedHabits.value && !archivedHabitsLoaded.value) await safe(loadArchivedHabits)
|
||||
if (showArchivedHabits.value && archiveState.value !== 'success') await loadArchivedHabits()
|
||||
}
|
||||
function refreshHabitDay() {
|
||||
const next = dateKey(new Date())
|
||||
@@ -434,8 +471,10 @@ async function loadHabits() {
|
||||
const data = await request(`/habits/grid?week=${week}`) as { habits?: Habit[] }
|
||||
habits.value = data.habits ?? []
|
||||
writeHabitGridCache(week, habits.value)
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '请求失败'
|
||||
return false
|
||||
} finally {
|
||||
if (!cached) busy.value = false
|
||||
}
|
||||
@@ -578,19 +617,23 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="!visibleHabits.length && !busy" class="empty-panel">{{ !showCompleted && habits.length ? '已完成的习惯已隐藏。' : '还没有习惯,从一件容易坚持的小事开始。' }}</div>
|
||||
<button class="archived-toggle habit-archive-toggle" type="button" @click="toggleArchivedHabits"><ArchiveRestore/>{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length})` : '已归档' }}</button>
|
||||
<div v-if="showArchivedHabits" class="archived-habits">
|
||||
<button v-for="h in archivedHabits" :key="h.id" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span>{{ h.name }}</span><small>查看详情</small></button>
|
||||
<p v-if="archivedHabitsLoaded && !archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯。</p>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="view === 'habits'" class="habit-archive-section">
|
||||
<button ref="habitArchiveToggle" class="archived-toggle habit-archive-toggle" type="button" :aria-expanded="showArchivedHabits" aria-controls="archived-habits-panel" @click="toggleArchivedHabits"><ArchiveRestore/><span>{{ archiveState === 'success' ? `已归档(${archivedHabits.length})` : '已归档' }}</span><ChevronRight :class="{ expanded: showArchivedHabits }" aria-hidden="true"/></button>
|
||||
<div v-if="showArchivedHabits" id="archived-habits-panel" class="archived-habits" aria-live="polite">
|
||||
<p v-if="archiveState === 'loading'" class="habit-archive-status">正在加载归档习惯…</p>
|
||||
<div v-else-if="archiveState === 'error'" class="habit-archive-status inline-error" role="alert"><span>{{ archiveError }}</span><button type="button" class="soft-button" @click="loadArchivedHabits">重试</button></div>
|
||||
<p v-else-if="archiveFlags.empty" class="empty-panel">暂无已归档习惯。</p>
|
||||
<button v-for="h in archiveFlags.list ? archivedHabits : []" :key="h.id" class="archived-habit-row" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span><b>{{ h.name }}</b><small>{{ formatArchivedAt(h.archived_at) }}</small></span><ChevronRight aria-hidden="true"/></button>
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="countdown-detail">
|
||||
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail">
|
||||
<article ref="habitDetailSheet" class="habit-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
|
||||
<header class="app-sheet__header"><div><small>习惯详情</small><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></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="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</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>
|
||||
</article>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
||||
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, performHabitRestore, 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', () => {
|
||||
@@ -196,6 +196,49 @@ describe('MVP view utilities', () => {
|
||||
expect(formatLocalShortDateTime('2026-04-31T12:00:00+08:00', { timeZone: 'UTC' })).toBe('未知时间')
|
||||
})
|
||||
|
||||
it('keeps a successful restore committed when the grid refresh fails', async () => {
|
||||
const events: string[] = []
|
||||
const result = await performHabitRestore({
|
||||
restore: async () => { events.push('restored') },
|
||||
commitRestore: () => { events.push('archive-removed') },
|
||||
refreshGrid: async () => false,
|
||||
})
|
||||
|
||||
expect(events).toEqual(['restored', 'archive-removed'])
|
||||
expect(result).toEqual({ restored: true, refreshed: false })
|
||||
})
|
||||
|
||||
it('invalidates a restored habit grid cache before reconciliation', () => {
|
||||
writeHabitGridCache('2026-09-07', [{ id: 'restored-habit' }])
|
||||
invalidateHabitGridCache()
|
||||
expect(readHabitGridCache('2026-09-07')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not commit a failed restore or attempt a grid refresh', async () => {
|
||||
const events: string[] = []
|
||||
await expect(performHabitRestore({
|
||||
restore: async () => { throw new Error('恢复失败') },
|
||||
commitRestore: () => { events.push('archive-removed') },
|
||||
refreshGrid: async () => { events.push('grid-refreshed') },
|
||||
})).rejects.toThrow('恢复失败')
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('formats archive timestamps locally and rejects every malformed calendar value', () => {
|
||||
expect(formatArchivedAt('2026-09-10T12:08:00Z', { timeZone: 'Asia/Shanghai' })).toMatch(/^归档于 2026\/9\/10 20:08$/)
|
||||
for (const value of [null, '', 'not-a-date', '2026-02-30T12:00:00Z', '2026-13-01T00:00:00Z', '2026-09-10T25:00:00Z']) {
|
||||
expect(formatArchivedAt(value, { timeZone: 'UTC' })).toBe('归档时间未知')
|
||||
}
|
||||
})
|
||||
|
||||
it('makes archive loading, error, and empty states mutually exclusive', () => {
|
||||
expect(archivePanelFlags('loading', 0)).toEqual({ loading: true, error: false, empty: false, list: false })
|
||||
expect(archivePanelFlags('error', 0)).toEqual({ loading: false, error: true, empty: false, list: false })
|
||||
expect(archivePanelFlags('success', 0)).toEqual({ loading: false, error: false, empty: true, list: false })
|
||||
expect(archivePanelFlags('success', 2)).toEqual({ loading: false, error: false, empty: false, list: true })
|
||||
expect(archivePanelFlags('idle', 0)).toEqual({ loading: false, error: false, empty: false, list: false })
|
||||
})
|
||||
|
||||
it('localizes known audit actions and entities and hides unknown actions', () => {
|
||||
expect(['create', 'update', 'complete', 'delete', 'archive', 'restore', 'move', 'import'].map(formatAuditAction)).toEqual(['创建', '更新', '完成', '删除', '归档', '恢复', '移动', '导入'])
|
||||
expect(['task', 'list', 'folder', 'countdown', 'backup'].map(formatAuditEntity)).toEqual(['任务', '清单', '文件夹', '倒数日', '备份'])
|
||||
|
||||
@@ -269,6 +269,25 @@ export function writeHabitGridCache<T>(week: string, habits: T[]) {
|
||||
habitGridCache = { week, habits }
|
||||
}
|
||||
|
||||
export function invalidateHabitGridCache() {
|
||||
habitGridCache = null
|
||||
}
|
||||
|
||||
export async function performHabitRestore(actions: {
|
||||
restore: () => Promise<unknown>
|
||||
commitRestore: () => void
|
||||
refreshGrid: () => Promise<boolean | unknown>
|
||||
}) {
|
||||
await actions.restore()
|
||||
actions.commitRestore()
|
||||
try {
|
||||
const refreshed = await actions.refreshGrid()
|
||||
return { restored: true as const, refreshed: refreshed !== false }
|
||||
} catch {
|
||||
return { restored: true as const, refreshed: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function readCountdownCache<T>() {
|
||||
if (!countdownCache) return null
|
||||
return { items: countdownCache.items as T[], archived: countdownCache.archived as T[] }
|
||||
@@ -369,6 +388,22 @@ export function habitActionState(cell?: { scheduled?: boolean; paused?: boolean
|
||||
return { writable: true, reason: '' }
|
||||
}
|
||||
|
||||
export type ArchivePanelState = 'idle' | 'loading' | 'success' | 'error'
|
||||
|
||||
export function archivePanelFlags(state: ArchivePanelState, count: number) {
|
||||
return {
|
||||
loading: state === 'loading',
|
||||
error: state === 'error',
|
||||
empty: state === 'success' && count === 0,
|
||||
list: state === 'success' && count > 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatArchivedAt(value?: string | null, options: { timeZone?: string } = {}) {
|
||||
const formatted = formatLocalShortDateTime(value, options)
|
||||
return formatted === '未知时间' ? '归档时间未知' : `归档于 ${formatted}`
|
||||
}
|
||||
|
||||
export function formatUserAgent(userAgent?: string | null): string {
|
||||
const ua = userAgent?.trim() ?? ''
|
||||
let device = '未知设备'
|
||||
|
||||
@@ -219,18 +219,20 @@ describe('request generation protection', () => {
|
||||
describe('habit request boundaries', () => {
|
||||
it('never loads archived habits for embedded Today habits and loads them lazily on Habits', () => {
|
||||
const mounted = habits.slice(habits.indexOf('onMounted(() =>'), habits.indexOf('onBeforeUnmount(() =>'))
|
||||
const archiveBlock = habits.slice(habits.indexOf('async function archiveHabit'), habits.indexOf('async function deleteHabit'))
|
||||
const archiveBlock = habits.slice(habits.indexOf('async function archiveHabit'), habits.indexOf('async function restoreHabit'))
|
||||
const loadBlock = habits.slice(habits.indexOf('async function loadArchivedHabits'), habits.indexOf('async function toggleArchivedHabits'))
|
||||
expect(mounted).not.toContain('loadArchivedHabits()')
|
||||
expect(archiveBlock).toContain("if (props.view === 'habits' && showArchivedHabits.value)")
|
||||
expect(habits).toContain('if (showArchivedHabits.value && !archivedHabitsLoaded.value)')
|
||||
expect(habits).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length})` : '已归档' }}")
|
||||
expect(loadBlock).toContain("if (props.view !== 'habits'")
|
||||
expect(habits).toContain("archiveState === 'success' ? `已归档(${archivedHabits.length})` : '已归档'")
|
||||
})
|
||||
|
||||
it('does not render an empty archive until loading succeeds and allows collapse-reopen retry', () => {
|
||||
it('does not render an empty archive until loading succeeds and retries after failure', () => {
|
||||
const toggleBlock = habits.slice(habits.indexOf('async function toggleArchivedHabits'), habits.indexOf('function refreshHabitDay'))
|
||||
expect(toggleBlock).toContain('showArchivedHabits.value && !archivedHabitsLoaded.value')
|
||||
expect(habits).toContain('v-if="archivedHabitsLoaded && !archivedHabits.length && !busy"')
|
||||
expect(habits).not.toContain('v-if="!archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯。')
|
||||
expect(toggleBlock).toContain("showArchivedHabits.value && archiveState.value !== 'success'")
|
||||
expect(habits).toContain('v-else-if="archiveFlags.empty"')
|
||||
expect(habits).toContain("v-else-if=\"archiveState === 'error'\"")
|
||||
expect(habits).toContain('@click="loadArchivedHabits">重试</button>')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ main{background:var(--surface-base)}
|
||||
.soft-button,.danger-button,.file-button,.secondary,.restore,.archived-toggle,.archived-countdowns button{background:var(--surface-raised);border-color:var(--border-cream);box-shadow:var(--highlight-inner)}
|
||||
.primary,.primary-small,.unified-fab{background:var(--accent);color:#fff}
|
||||
.danger-button{color:var(--danger);border-color:#dba99d;background:#fff8f5}.inline-error,.purge-list-error{color:var(--danger);background:#fff0ec}.task-check[aria-pressed="true"] .task-check-mark,.habit-row.done .habit-check .task-check-mark{background:var(--success);border-color:var(--success)}
|
||||
@media(max-width:930px){.habit-archive-toggle{width:100%;min-height:44px;appearance:none;background:var(--surface-raised);border:1px solid var(--border-cream);box-shadow:none;justify-content:flex-start}.archived-habits{width:100%;display:grid;gap:0}.archived-habits>button{width:100%;min-height:44px;appearance:none;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;padding:10px 12px;color:inherit;text-align:left;background:var(--surface-raised);border:0;border-bottom:1px solid var(--border-cream);border-radius:0;font:inherit}.archived-habits>button span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.archived-habits>button small{color:var(--muted);white-space:nowrap}}
|
||||
.habit-archive-section{display:grid;gap:0}.habit-archive-toggle{width:100%;min-height:44px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;text-align:left}.habit-archive-toggle>span{min-width:0}.habit-archive-toggle>svg:last-child{transition:transform .16s ease}.habit-archive-toggle>svg.expanded{transform:rotate(90deg)}.archived-habits{width:100%;display:grid;gap:0;background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:var(--radius-card);overflow:hidden}.archived-habit-row{width:100%;min-height:44px;appearance:none;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;padding:10px 12px;color:inherit;text-align:left;background:var(--surface-raised);border:0;border-bottom:1px solid var(--border-cream);font:inherit}.archived-habit-row:last-child{border-bottom:0}.archived-habit-row>span{min-width:0;display:grid;gap:3px}.archived-habit-row b,.archived-habit-row small{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.archived-habit-row small{color:var(--muted);font-size:11px}.archived-habit-row>svg{flex:none}.habit-archive-status{min-height:44px;margin:0;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px}.app-sheet__danger{display:flex;gap:8px}.app-sheet__danger>button{min-height:44px}.habit-restore-button{flex:1}.habit-delete-button{flex:1}
|
||||
@media(max-width:930px){.habit-archive-section{width:100%;gap:0}.habit-archive-toggle{width:100%;min-height:44px;appearance:none;background:var(--surface-raised);border:1px solid var(--border-cream);box-shadow:none}.archived-habits{width:100%;display:grid;gap:0}.archived-habit-row{width:100%;min-height:44px;appearance:none;grid-template-columns:minmax(0,1fr) auto;background:var(--surface-raised);border-bottom:1px solid var(--border-cream)}.habit-detail-sheet.app-sheet--detail{width:100%;border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}}
|
||||
input,select,textarea,.search{background:var(--surface-raised);border-color:var(--border-cream);border-radius:var(--radius-control);box-shadow:var(--highlight-inner)}
|
||||
.search input,.detail-title textarea{background:transparent;box-shadow:none}
|
||||
.sidebar-popover button,.sidebar-action-sheet .app-sheet__body button,.app-sheet--actions .app-sheet__body>button,.more-sheet>button{background:var(--surface-raised)}
|
||||
|
||||
@@ -267,16 +267,56 @@ describe('mobile list row language', () => {
|
||||
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
|
||||
})
|
||||
|
||||
it('provides a minimal archived-habit viewing path', () => {
|
||||
it('provides an archived-habit viewing path', () => {
|
||||
expect(mvpPanel).toContain("request('/habits?archived=true')")
|
||||
expect(mvpPanel).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length})` : '已归档' }}")
|
||||
expect(mvpPanel).toContain("archiveState === 'success' ? `已归档(${archivedHabits.length})` : '已归档'")
|
||||
expect(mvpPanel).toContain('class="archived-toggle habit-archive-toggle"')
|
||||
expect(mvpPanel).toContain('v-for="h in archivedHabits"')
|
||||
expect(css).toMatch(/@media\(max-width:930px\)\{\.habit-archive-toggle\{[^}]*width:100%;[^}]*min-height:44px;[^}]*appearance:none;[^}]*background:var\(--surface-raised\);[^}]*border:1px solid var\(--border-cream\)/)
|
||||
expect(mvpPanel).toContain('archiveFlags.list ? archivedHabits : []')
|
||||
expect(css).toMatch(/@media\(max-width:930px\)\{\.habit-archive-section\{[^}]*width:100%/)
|
||||
expect(css).toMatch(/\.archived-habits\{[^}]*width:100%;[^}]*display:grid/)
|
||||
expect(css).toMatch(/\.archived-habits>button\{[^}]*width:100%;[^}]*min-height:44px;[^}]*appearance:none;[^}]*grid-template-columns:minmax\(0,1fr\) auto;[^}]*background:var\(--surface-raised\);[^}]*border-bottom:1px solid var\(--border-cream\)/)
|
||||
expect(css).toMatch(/\.archived-habits>button span\{[^}]*min-width:0;[^}]*overflow:hidden;[^}]*text-overflow:ellipsis;[^}]*white-space:nowrap/)
|
||||
expect(css).toMatch(/\.archived-habits>button small\{[^}]*white-space:nowrap/)
|
||||
expect(css).toMatch(/\.archived-habit-row\{[^}]*width:100%;[^}]*min-height:44px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
|
||||
expect(css).toMatch(/\.archived-habit-row b,\.archived-habit-row small\{[^}]*min-width:0;[^}]*overflow:hidden;[^}]*text-overflow:ellipsis;[^}]*white-space:nowrap/)
|
||||
})
|
||||
|
||||
it('renders a separate accessible archive section with explicit states and lifecycle actions', () => {
|
||||
const activeList = mvpPanel.slice(mvpPanel.indexOf('<div v-if="view === \'habits\'" class="habit-list">'), mvpPanel.indexOf('<section v-if="view === \'habits\'" class="habit-archive-section"'))
|
||||
const todayList = mvpPanel.slice(mvpPanel.indexOf('class="habit-list today-habit-list"'), mvpPanel.indexOf('<!-- 完整习惯列表 -->'))
|
||||
expect(activeList).not.toContain('habit-archive-toggle')
|
||||
expect(todayList).not.toContain('habit-archive-section')
|
||||
expect(mvpPanel).toContain('class="habit-archive-section"')
|
||||
expect(mvpPanel).toContain('aria-controls="archived-habits-panel"')
|
||||
expect(mvpPanel).toContain(':aria-expanded="showArchivedHabits"')
|
||||
expect(mvpPanel).toContain('id="archived-habits-panel"')
|
||||
expect(mvpPanel).toContain('archiveState === \'loading\'')
|
||||
expect(mvpPanel).toContain('archiveState === \'error\'')
|
||||
expect(mvpPanel).toContain('@click="loadArchivedHabits"')
|
||||
expect(mvpPanel).toContain('formatArchivedAt(h.archived_at)')
|
||||
expect(mvpPanel).toContain('<ChevronRight aria-hidden="true"/>')
|
||||
expect(mvpPanel).toContain('@click="restoreHabit(selectedHabit)"')
|
||||
expect(mvpPanel).toContain("method: 'POST'")
|
||||
expect(mvpPanel).toContain('archivedHabits.value = archivedHabits.value.filter((item) => item.id !== h.id)')
|
||||
expect(mvpPanel).toContain('恢复习惯')
|
||||
})
|
||||
|
||||
it('preserves archived detail after restore or purge failures and falls focus back to disclosure after removal', () => {
|
||||
const restoreBlock = mvpPanel.slice(mvpPanel.indexOf('async function restoreHabit'), mvpPanel.indexOf('async function deleteHabit'))
|
||||
const deleteBlock = mvpPanel.slice(mvpPanel.indexOf('async function deleteHabit'), mvpPanel.indexOf('async function loadArchivedHabits'))
|
||||
expect(restoreBlock).toContain('catch (reason)')
|
||||
expect(deleteBlock).toContain('catch (reason)')
|
||||
expect(restoreBlock).toContain('performHabitRestore({')
|
||||
expect(restoreBlock).toContain('invalidateHabitGridCache()')
|
||||
expect(restoreBlock).toContain("result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试'")
|
||||
expect(restoreBlock.indexOf('selectedHabit.value = null')).toBeGreaterThan(restoreBlock.indexOf("method: 'POST'"))
|
||||
expect(deleteBlock.indexOf('selectedHabit.value = null')).toBeGreaterThan(deleteBlock.indexOf("method: 'DELETE'"))
|
||||
expect(mvpPanel).toContain('habitArchiveToggle.value?.focus()')
|
||||
expect(mvpPanel).toContain('ref="habitArchiveToggle"')
|
||||
})
|
||||
|
||||
it('styles archive rows as 44px full-width desktop rows and a mobile bottom sheet detail', () => {
|
||||
expect(css).toMatch(/\.habit-archive-toggle\{[^}]*min-height:44px/)
|
||||
expect(css).toMatch(/\.archived-habit-row\{[^}]*width:100%;[^}]*min-height:44px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
|
||||
expect(css).toContain('@media(max-width:930px){.habit-archive-section')
|
||||
expect(css).toContain('.habit-detail-sheet.app-sheet--detail{')
|
||||
})
|
||||
|
||||
it('reuses the habit composer for edits and patches only changed fields', () => {
|
||||
|
||||
Reference in New Issue
Block a user