refactor: unify mobile list rows
ci / gitleaks (push) Successful in 6s
ci / docker (push) Successful in 4m43s

This commit is contained in:
2026-09-08 18:55:24 +08:00
parent bbb797c411
commit fae0f7a556
3 changed files with 56 additions and 4 deletions
+31 -2
View File
@@ -23,6 +23,10 @@ const habitName = ref('')
const habitType = ref<'boolean' | 'numeric'>('boolean')
const habitTarget = ref(1)
const habitComposerOpen = ref(false)
const selectedHabit = ref<Habit | null>(null)
const habitDetailClickSuppressed = ref(false)
const habitDetailSheet = ref<HTMLElement | null>(null)
let habitDetailOpener: HTMLElement | null = null
const habitComposeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
const habitComposeStyle = computed(() => ({ '--fab-origin-x': `${habitComposeOrigin.value.x}px`, '--fab-origin-y': `${habitComposeOrigin.value.y}px` }))
const habitNameInput = ref<HTMLInputElement | null>(null)
@@ -210,6 +214,10 @@ async function applyHabitSwipe(h: Habit, deltaX: number) {
}
}
function suppressHabitDetailClick() {
habitDetailClickSuppressed.value = true
window.setTimeout(() => { habitDetailClickSuppressed.value = false }, 0)
}
async function finishHabitSwipe(h: Habit, event: TouchEvent) {
const start = habitSwipeStart.value
habitSwipeStart.value = null
@@ -223,6 +231,7 @@ async function finishHabitSwipe(h: Habit, event: TouchEvent) {
? ((deltaX > 0 && current < (h.target ?? 1)) || (deltaX < 0 && current > 0))
: (isDone(h, todayKey.value) ? deltaX < 0 : deltaX > 0)
if (touch && expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
suppressHabitDetailClick()
await applyHabitSwipe(h, deltaX)
}
}
@@ -238,6 +247,7 @@ async function finishHabitPointer(h: Habit, event: PointerEvent) {
? ((deltaX > 0 && current < (h.target ?? 1)) || (deltaX < 0 && current > 0))
: (isDone(h, todayKey.value) ? deltaX < 0 : deltaX > 0)
if (expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
suppressHabitDetailClick()
void applyHabitSwipe(h, deltaX)
}
}
@@ -281,11 +291,22 @@ function openHabitComposer(origin?: { x: number; y: number }) {
void nextTick(() => habitNameInput.value?.focus())
}
function closeHabitComposer() { habitComposerOpen.value = false }
function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
if (habitDetailClickSuppressed.value) return
habitDetailOpener = opener ?? document.activeElement as HTMLElement | null
selectedHabit.value = h
void nextTick(() => habitDetailSheet.value?.focus())
}
function closeHabitDetail() {
selectedHabit.value = null
void nextTick(() => habitDetailOpener?.focus())
}
defineExpose({ openHabitComposer })
async function archiveHabit(h: Habit) {
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
await safe(async () => {
await request(`/habits/${h.id}`, { method: 'DELETE' })
selectedHabit.value = null
await loadHabits()
emit('notice', '习惯已归档')
})
@@ -433,15 +454,23 @@ onBeforeUnmount(() => {
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--reorder-y': `${habitReorder?.id === h.id ? habitReorder.offsetY : 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<button class="drag-handle habit-drag-handle" :disabled="hideCompletedHabits" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
<button class="task-check habit-check" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
<div class="habit-main">
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
<span class="habit-name">{{ h.name }}</span>
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
<progress v-if="h.kind === 'numeric'" class="habit-progress-bar" :value="habitProgressValue(h)" :max="habitProgressMax(h)" :aria-label="`${h.name}进度:${habitProgressText(h)}`"></progress>
</div>
<button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button>
</article>
<div v-if="!habits.length && !busy" class="empty-panel">还没有习惯从一件容易坚持的小事开始</div>
</div>
<Transition name="countdown-detail">
<div v-if="selectedHabit" class="habit-detail-mask" @click.self="closeHabitDetail">
<article ref="habitDetailSheet" class="habit-detail-sheet" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
<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="habit-detail-progress"><span>今日进度</span><strong>{{ habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
<footer><button type="button" class="danger-text" @click="archiveHabit(selectedHabit)"><Trash2/>归档习惯</button></footer>
</article>
</div>
</Transition>
</template>
<!-- 设置与数据 -->
File diff suppressed because one or more lines are too long
+21
View File
@@ -30,6 +30,27 @@ describe('mobile navigation styles', () => {
})
})
describe('mobile list row language', () => {
it('uses one quiet bordered row surface for tasks, habits, and countdowns on mobile', () => {
expect(css).toContain('@media(max-width:930px){.task-row,.habit-row,.countdown-row{')
expect(css).toMatch(/@media\(max-width:930px\)\{\.task-row,\.habit-row,\.countdown-row\{[^}]*background:#fff;[^}]*border:1px solid var\(--line\);[^}]*border-radius:13px;[^}]*box-shadow:none/)
expect(css).toContain('.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}')
expect(css).toContain('.task-main strong,.habit-name,.countdown-main>b{font-size:14px;')
expect(css).toContain('.meta,.habit-progress,.countdown-main>small,.countdown-state small{font-size:11px;')
})
it('keeps destructive habit actions in a detail sheet instead of the list row', () => {
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
expect(mvpPanel).toContain('class="habit-detail-mask"')
expect(mvpPanel).toContain('class="habit-detail-sheet"')
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('ref="habitDetailSheet"')
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
})
})
describe('task and habit row decoration', () => {
it('removes the redundant habit refresh action', () => {
expect(mvpPanel).not.toContain('<RefreshCw />刷新')