style: simplify mobile task views
ci / gitleaks (push) Successful in 1m24s
ci / docker (push) Successful in 6m1s

This commit is contained in:
2026-09-16 22:11:01 +08:00
parent 6c234d7d82
commit bdf2a53fa9
11 changed files with 343 additions and 39 deletions
+1 -1
View File
@@ -1523,7 +1523,7 @@ onUnmounted(() => {
<button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span>
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span>
</article>
</template>
<div v-if="activeView==='today' && !query && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
+18
View File
@@ -5,6 +5,7 @@ import CountdownPanel from './CountdownPanel.vue'
import { invalidateCountdownCache } from './lib/mvp-utils'
const source = readFileSync('src/CountdownPanel.vue', 'utf8')
const css = readFileSync('src/style.css', 'utf8')
const cleanups: Array<() => void> = []
const countdown = { id:'c1', title:'发布日', event_date:'2026-09-20', display_date:'2026-09-20', kind:'countdown', repeat_rule:'none', icon:'', pinned:false, archived_at:null, days:4, calendar_mode:'solar', lunar_year:null, lunar_month:null, lunar_day:null, ignore_year:false, lunar_text:null, updated_at:'v1' }
const countdownB = { ...countdown, id:'c2', title:'旅行日', event_date:'2026-09-24', display_date:'2026-09-24', days:8 }
@@ -108,6 +109,23 @@ describe('countdown modal accessibility', () => {
expect(source).not.toContain('<h3>{{group.title}}</h3>')
})
it('keeps active rows compact and archive controls accessible', () => {
expect(source).not.toContain('class="countdown-hero"')
expect(source).toContain('class="countdown-summary"')
expect(source).not.toContain('<template v-if="secondaryDate(item)">')
expect(source).not.toContain('visibleBadges(item)')
expect(source).not.toContain('class="pinned-icon"')
expect(source).toContain(':aria-expanded="showArchived"')
expect(source).toContain('aria-controls="archived-countdowns"')
expect(source).toContain('id="archived-countdowns"')
})
it('keeps archived countdowns as one surface with separator-only mobile rows', () => {
expect(css).toMatch(/\.archived-countdowns\.archived-countdowns\{[^}]*background:var\(--surface-raised\);[^}]*border:1px solid var\(--border-cream\);[^}]*border-radius:var\(--radius-list\);[^}]*box-shadow:none/)
expect(css).toMatch(/\.archived-countdowns\.archived-countdowns article\{[^}]*border:0;[^}]*border-radius:0;[^}]*box-shadow:none/)
expect(css).toContain('.archived-countdowns.archived-countdowns article+article{border-top:1px solid var(--border-cream)}')
})
it('keeps the empty state directly actionable', () => {
expect(source).toContain('添加第一个重要日子')
expect(source).toContain('@click="openFromEmpty"')
+4 -8
View File
@@ -52,9 +52,6 @@ function repeatBadge(item: Countdown) {
if (item.calendar_mode === 'lunar') return item.ignore_year ? '每年' : (item.repeat_rule !== 'none' ? repeatLabel(item.repeat_rule) : '')
return item.repeat_rule !== 'none' ? repeatLabel(item.repeat_rule) : ''
}
function visibleBadges(item: Countdown) {
return [countdownKindLabel(item.kind), item.calendar_mode === 'lunar' ? '农历' : '', repeatBadge(item)].filter(Boolean).slice(0, 2)
}
function sorted(itemsToSort: Countdown[]) {
return [...itemsToSort].sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.days - b.days || a.title.localeCompare(b.title, 'zh-Hans-CN'))
}
@@ -186,7 +183,7 @@ onBeforeUnmount(() => {
<template>
<section class="countdown-view" :class="{ loading:busy }">
<div class="countdown-content" :inert="open || Boolean(detailItem)" :aria-hidden="open || detailItem ? 'true' : undefined">
<header class="countdown-hero"><small>{{countdownSummary}}</small></header>
<small class="countdown-summary">{{countdownSummary}}</small>
<p v-if="error" class="inline-error">{{error}}</p>
<div v-if="items.length" class="countdown-layout">
<button v-if="focusItem" type="button" class="countdown-focus" :class="`kind-${focusItem.kind}`" @click="openDetail(focusItem)">
@@ -198,16 +195,15 @@ onBeforeUnmount(() => {
<section v-for="group in countdownGroups" :key="group.key" class="countdown-group">
<h3 v-if="group.title">{{group.title}}</h3>
<button v-for="item in group.items" :key="item.id" type="button" class="countdown-row" :class="[`kind-${item.kind}`, { pinned:item.pinned, today:item.days===0, past:item.days<0 }]" @click="openDetail(item)">
<span class="countdown-main"><b>{{item.title}}</b><small>{{primaryDate(item)}}<template v-if="secondaryDate(item)"> · {{secondaryDate(item)}}</template></small><span class="countdown-badges"><em v-for="badge in visibleBadges(item)" :key="badge">{{badge}}</em></span></span>
<span class="countdown-main"><b>{{item.title}}<small v-if="item.pinned" class="countdown-pinned-mark"> · 置顶</small></b><small>{{primaryDate(item)}}</small></span>
<span class="countdown-state"><strong>{{item.days===0?'今天':Math.abs(item.days)}}</strong><small>{{countdownDayText(item.days)}}</small></span>
<Pin v-if="item.pinned" class="pinned-icon"/>
</button>
</section>
</div>
</div>
<div v-else-if="!busy" class="countdown-empty"><CalendarHeart/><b>还没有重要日子</b><span>生日、纪念日,或一场期待已久的旅行</span><button type="button" class="primary-small" @click="openFromEmpty">添加第一个重要日子</button></div>
<button v-if="archived.length" class="archived-toggle" :disabled="busy" @click="showArchived=!showArchived"><ArchiveRestore/>已归档{{archived.length}}</button>
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button :disabled="busy" @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" :disabled="busy" @click="purge(item)"><Trash2/>永久删除</button></article></div>
<button v-if="archived.length" class="archived-toggle" type="button" :disabled="busy" :aria-expanded="showArchived" aria-controls="archived-countdowns" @click="showArchived=!showArchived"><ArchiveRestore/>已归档{{archived.length}}</button>
<div v-if="showArchived" id="archived-countdowns" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><span><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small></span><button :disabled="busy" @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" :disabled="busy" @click="purge(item)"><Trash2/>永久删除</button></article></div>
</div>
<AppSheet :open="Boolean(detailItem)" variant="detail" panel-class="countdown-detail-sheet" title-id="countdown-detail-title" initial-focus="button[aria-label='关闭详情']" :busy="busy" @close="closeDetail">
<template v-if="detailItem">
+46
View File
@@ -1,7 +1,11 @@
import { readFileSync } from 'node:fs'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick } from 'vue'
import MemoPanel from './MemoPanel.vue'
const source = readFileSync('src/MemoPanel.vue', 'utf8')
const memoCss = readFileSync('src/memo.css', 'utf8')
const cleanups: Array<() => void> = []
const item = { id: 'm1', title: '第一条', excerpt: '摘要', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
@@ -35,6 +39,48 @@ async function mount(request: RequestMock, onNotice?: (message: string) => void,
afterEach(() => { vi.useRealTimers(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
describe('MemoPanel', () => {
it('uses controlled mobile visibility, preserves query, and restores focus for Escape and toggle close', async () => {
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
const request = vi.fn(async (): Promise<unknown> => ({ items: [], total: 0 }))
const { host } = await mount(request)
const toggle = host.querySelector<HTMLButtonElement>('[aria-label="展开搜索备忘录"]')!
const panel = host.querySelector<HTMLElement>('#memo-search-panel')!
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(panel.hidden).toBe(true)
toggle.click(); await nextTick()
const input = host.querySelector<HTMLInputElement>('#memo-search-input')!
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(panel.hidden).toBe(false)
expect(document.activeElement).toBe(input)
input.value = '保留'; input.dispatchEvent(new Event('input'))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await nextTick()
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(panel.hidden).toBe(true)
expect(input.value).toBe('保留')
expect(document.activeElement).toBe(toggle)
toggle.click(); await nextTick()
expect(document.activeElement).toBe(input)
toggle.click(); await nextTick()
expect(panel.hidden).toBe(true)
expect(input.value).toBe('保留')
expect(document.activeElement).toBe(toggle)
expect(host.querySelector('[data-scope="active"]')?.getAttribute('aria-selected')).toBe('true')
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
})
it('keeps desktop search visible and at least 320px wide while mobile fills without overflow', () => {
expect(source).toContain('const mobileLayout = ref(window.innerWidth <= 930)')
expect(source).toContain(':hidden="mobileLayout && !mobileSearchOpen"')
expect(source).not.toContain('mobileSearchOpen || Boolean(query)')
expect(memoCss).toMatch(/\.memo-search-panel\{[^}]*flex:0 1 420px;[^}]*width:min\(420px,100%\);[^}]*min-width:320px/)
expect(memoCss).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.memo-search-panel\{[^}]*width:100%;[^}]*min-width:0/)
expect(memoCss).toContain('.memo-search-panel[hidden]{display:none}')
expect(memoCss).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.memo-search\{[^}]*width:100%;[^}]*min-width:0/)
})
it('loads active memos in server order and appends the next 50', async () => {
const request = vi.fn(async (path: string) => path.includes('page=2')
? { items: [{ ...item, id: 'm2', title: '第二页' }], total: 51 }
+15 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Archive, FileText, Search } from 'lucide-vue-next'
import { Archive, FileText, Search, X } from 'lucide-vue-next'
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
@@ -22,7 +22,10 @@ const selectedToken = ref(0)
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
const searchInput = ref<HTMLInputElement | null>(null)
const mobileDetail = ref(window.innerWidth <= 930)
const searchToggle = ref<HTMLButtonElement | null>(null)
const mobileLayout = ref(window.innerWidth <= 930)
const mobileDetail = ref(mobileLayout.value)
const mobileSearchOpen = ref(false)
let detailOpener: HTMLElement | null = null
let timer: number | undefined
let generation = 0
@@ -182,7 +185,14 @@ function removeItem(id: string, selectionToken: number) {
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
}
function removeRestoredItem(memo: MemoRecord, selectionToken: number) { removeItem(memo.id, selectionToken) }
function updateLayout() { mobileDetail.value = window.innerWidth <= 930 }
function openMobileSearch() { mobileSearchOpen.value = true; void nextTick(() => searchInput.value?.focus()) }
function closeMobileSearch() { mobileSearchOpen.value = false; void nextTick(() => searchToggle.value?.focus()) }
function handleSearchKeydown(event: KeyboardEvent) { if (event.key === 'Escape' && mobileLayout.value) { event.preventDefault(); closeMobileSearch() } }
function clearSearch() { query.value = ''; void nextTick(() => searchInput.value?.focus()) }
function updateLayout() {
mobileLayout.value = window.innerWidth <= 930
mobileDetail.value = mobileLayout.value
}
watch(query, () => {
generation += 1
loading.value = false
@@ -201,7 +211,8 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
<div class="memo-toolbar">
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
<label class="memo-search"><Search/><input ref="searchInput" v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…"></label>
<button ref="searchToggle" class="memo-search-toggle" type="button" aria-label="展开搜索备忘录" :aria-expanded="mobileLayout ? mobileSearchOpen : true" aria-controls="memo-search-panel" @click="mobileSearchOpen ? closeMobileSearch() : openMobileSearch()"><Search/></button>
<div id="memo-search-panel" class="memo-search-panel" :class="{'is-open':mobileSearchOpen}" :hidden="mobileLayout && !mobileSearchOpen"><label class="memo-search"><Search/><input id="memo-search-input" ref="searchInput" v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…" @keydown="handleSearchKeydown"></label><button v-if="query" type="button" class="memo-search-clear" aria-label="清空搜索" @click="clearSearch"><X/></button></div>
</div>
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录</div>
+1 -8
View File
@@ -743,9 +743,6 @@ onBeforeUnmount(() => {
<!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 -->
<template v-if="view === 'habits' || view === 'today-habits'">
<header v-if="view === 'habits'" class="view-intro">
<small>把想坚持的事变成每天的日常</small>
</header>
<!-- 今日习惯只展示今天该做的习惯复用正式习惯行样式 -->
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
@@ -836,15 +833,11 @@ onBeforeUnmount(() => {
<!-- 设置与数据 -->
<template v-else>
<header class="view-intro">
<div><small>备份迁移与安全</small></div>
</header>
<div class="settings-sections">
<section class="settings-group settings-data"><header><h2>数据</h2><p>完整备份包含全部数据与附件CSV / JSON 继续用于旧格式兼容</p></header><div class="settings-row"><span><b>完整 ZIP 备份</b><small>下载可完整恢复的版本化归档</small></span><button class="soft-button" :disabled="backupBusy" @click="exportData"><Download />导出 ZIP</button></div><div class="settings-row settings-restore-row"><span><b>恢复备份</b><small>{{ restoreFile?.name || '支持 .zip、.csv、.json' }}</small></span><label class="file-button" :class="{ disabled: backupBusy }"><ArchiveRestore />选择文件<input ref="restoreInput" type="file" accept=".zip,.csv,.json,application/zip,application/json,text/csv" :disabled="backupBusy" @change="selectRestoreFile"></label></div><div v-if="restoreFile" class="settings-row"><span><b>恢复方式</b><small>{{ legacyRestore ? '旧格式仅支持合并恢复' : '变更方式后需要重新预检' }}</small></span><select v-model="restoreMode" aria-label="恢复方式" :disabled="backupBusy || legacyRestore"><option value="merge">合并</option><option v-if="!legacyRestore" value="replace">替换现有数据</option></select></div><div v-if="restoreFile && !legacyRestore" class="settings-row"><span><b>备份预检</b><small>恢复前检查格式、关联与附件</small></span><button class="soft-button" :disabled="backupBusy" @click="runPreflight">{{ backupBusy ? '检查中…' : '开始预检' }}</button></div><div v-if="legacyRestore" class="backup-preflight legacy"><b>旧格式兼容恢复</b><p>旧格式将在恢复时校验,不支持完整预检或 Replace。</p><button class="danger-button" :disabled="backupBusy" @click="restore">合并旧格式</button></div><div v-else-if="restorePreflight" class="backup-preflight" :class="{ invalid: !restorePreflight.valid }"><b>{{ restorePreflight.valid ? '预检通过' : '备份不可恢复' }}</b><dl><div v-if="restorePreflight.version"><dt>版本</dt><dd>v{{ restorePreflight.version }}</dd></div><div><dt>数据记录</dt><dd>{{ backupEntityTotal }}</dd></div><div><dt>附件</dt><dd>{{ restorePreflight.attachment_count ?? restorePreflight.entities.attachments ?? 0 }} 个</dd></div></dl><ul v-if="restorePreflight.warnings?.length"><li v-for="warning in restorePreflight.warnings" :key="warning">{{ warning }}</li></ul><ul v-if="restorePreflight.destructive_summary?.length" class="destructive-summary"><li v-for="item in restorePreflight.destructive_summary" :key="item">{{ item }}</li></ul><button class="danger-button" :disabled="backupBusy || !restorePreflight.valid" @click="restore">{{ restoreMode === 'replace' ? '替换并恢复' : '合并并恢复' }}</button></div><p v-if="backupError" class="inline-error" role="alert">{{ backupError }}</p></section>
<section class="settings-group settings-data"><header><h2>数据</h2><p>完整备份包含全部数据与附件CSV / JSON 继续用于旧格式兼容</p></header><div class="settings-row"><span><b>完整 ZIP 备份</b><small>下载可完整恢复的版本化归档</small></span><button class="soft-button" :disabled="backupBusy" @click="exportData"><Download />导出 ZIP</button></div><div class="settings-row settings-restore-row"><span><b>恢复备份</b><small>{{ restoreFile?.name || '支持 .zip、.csv、.json' }}</small></span><label class="file-button" :class="{ disabled: backupBusy }"><ArchiveRestore />选择文件<input ref="restoreInput" type="file" accept=".zip,.csv,.json,application/zip,application/json,text/csv" :disabled="backupBusy" @change="selectRestoreFile"></label></div><div v-if="restoreFile" class="settings-row"><span><b>恢复方式</b><small>{{ legacyRestore ? '旧格式仅支持合并恢复' : '变更方式后需要重新预检' }}</small><small v-if="restoreMode==='replace'" class="restore-replace-warning">替换恢复会覆盖当前数据,请先导出完整备份。</small></span><select v-model="restoreMode" aria-label="恢复方式" :disabled="backupBusy || legacyRestore"><option value="merge">合并</option><option v-if="!legacyRestore" value="replace">替换现有数据</option></select></div><div v-if="restoreFile && !legacyRestore" class="settings-row"><span><b>备份预检</b><small>恢复前检查格式、关联与附件</small></span><button class="soft-button" :disabled="backupBusy" @click="runPreflight">{{ backupBusy ? '检查中…' : '开始预检' }}</button></div><div v-if="legacyRestore" class="backup-preflight legacy"><b>旧格式兼容恢复</b><p>旧格式将在恢复时校验,不支持完整预检或 Replace。</p><button class="danger-button" :disabled="backupBusy" @click="restore">合并旧格式</button></div><div v-else-if="restorePreflight" class="backup-preflight" :class="{ invalid: !restorePreflight.valid }"><b>{{ restorePreflight.valid ? '预检通过' : '备份不可恢复' }}</b><dl><div v-if="restorePreflight.version"><dt>版本</dt><dd>v{{ restorePreflight.version }}</dd></div><div><dt>数据记录</dt><dd>{{ backupEntityTotal }}</dd></div><div><dt>附件</dt><dd>{{ restorePreflight.attachment_count ?? restorePreflight.entities.attachments ?? 0 }} 个</dd></div></dl><ul v-if="restorePreflight.warnings?.length"><li v-for="warning in restorePreflight.warnings" :key="warning">{{ warning }}</li></ul><ul v-if="restorePreflight.destructive_summary?.length" class="destructive-summary"><li v-for="item in restorePreflight.destructive_summary" :key="item">{{ item }}</li></ul><button class="danger-button" :disabled="backupBusy || !restorePreflight.valid" @click="restore">{{ restoreMode === 'replace' ? '替换并恢复' : '合并并恢复' }}</button></div><p v-if="backupError" class="inline-error" role="alert">{{ backupError }}</p></section>
<section class="settings-group"><header><h2>账户与安全</h2><p>修改密码后当前设备保持登录其他设备自动退出</p></header><form class="password-form settings-form" @submit.prevent="changePassword"><label>当前密码<input v-model="currentPassword" type="password" autocomplete="current-password" required aria-label="当前密码"></label><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="新密码" placeholder="至少 12 位"></label><label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="确认新密码"></label><p v-if="passwordError" class="inline-error" role="alert">{{passwordError}}</p><button class="primary-small" :disabled="passwordBusy || !currentPassword || !newPassword || !confirmPassword">{{passwordBusy?'正在修改':'修改密码'}}</button></form></section>
<section class="settings-group"><header><h2>登录设备</h2><div class="session-card-actions"><p>可撤销其他设备的登录</p><button v-if="sessions.some((s) => !s.current)" type="button" class="danger-text session-revoke-all" :disabled="busy" @click="revokeOtherSessions">撤销其他所有会话</button></div></header><div v-for="s in sessions" :key="s.id" class="settings-row session-row"><span class="session-copy"><b class="session-title">{{ s.current ? '当前设备' : '其他设备' }}</b><small class="session-meta"><span class="session-device">{{ formatUserAgent(s.user_agent) }}</span><span aria-hidden="true"> · </span><time :datetime="s.last_seen_at ?? s.created_at">{{ formatLocalShortDateTime(s.last_seen_at ?? s.created_at) }}</time></small></span><button v-if="!s.current" class="danger-text session-revoke" :aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length" class="settings-empty">没有可显示的会话</p></section>
<section class="settings-group"><header><h2>活动</h2><p>最近的账户和数据操作</p></header><div v-for="(row, i) in audit" :key="row.id || i" class="settings-row audit-row"><div class="audit-copy"><span class="audit-action">{{ formatAuditAction(row.action ?? row.event) }}{{ formatAuditAction(row.action ?? row.event) === '其他操作' ? '' : formatAuditEntity(row.entity_type) }}</span><time :datetime="row.created_at ?? row.timestamp">{{ formatLocalShortDateTime(row.created_at ?? row.timestamp) }}</time></div></div><p v-if="!audit.length" class="settings-empty">暂无活动记录</p></section>
<section class="settings-group settings-danger"><header><h2>危险操作</h2><p>替换恢复会覆盖当前数据请先导出完整备份</p></header></section>
</div>
</template>
<AppDialog ref="appDialog" />
+2 -2
View File
@@ -1,3 +1,3 @@
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-search-toggle{display:none}.memo-search-panel{flex:0 1 420px;width:min(420px,100%);min-width:320px;display:flex;align-items:center;gap:4px}.memo-search-clear{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent;color:var(--muted)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;flex:1 1 auto;width:100%;min-width:0;display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;gap:8px}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:9px}.memo-search-toggle{width:44px;height:44px;display:grid;place-items:center;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);color:var(--text-secondary)}.memo-search-panel{grid-column:1/-1;width:100%;min-width:0;max-height:0;opacity:0;overflow:hidden;pointer-events:none;display:flex;transition:max-height .18s ease,opacity .15s ease}.memo-search-panel.is-open{max-height:44px;opacity:1;pointer-events:auto}.memo-search-panel[hidden]{display:none}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
File diff suppressed because one or more lines are too long
+17 -11
View File
@@ -36,7 +36,7 @@ describe('unified task due display', () => {
expect(app).toContain('</span><span v-if="activeView===\'trash\'" class="task-actions">')
expect(app).not.toContain('restoreTask(subtask)')
expect(app).not.toContain('purgeTask(subtask)')
expect(app).toContain('<span v-else class="task-actions"><button class="icon ghost task-detail-trigger"')
expect(app).not.toContain('task-detail-trigger')
expect(app).toContain('<span v-if="node.task.priority" class="priority"')
expect(app).toContain('v-if="node.subtasks.length" class="meta"')
expect(app).toContain('<span class="meta-item"><ListChecks/>')
@@ -218,24 +218,26 @@ describe('solid cream material system', () => {
})
describe('approved UI detail direction', () => {
it('uses one More detail entry for ordinary parent rows while preserving Trash and overdue actions', () => {
it('opens ordinary and overdue task details from the task body while preserving Trash actions', () => {
const ordinaryStart = app.indexOf('<section :id="activeView===\'today\' ? \'today-tasks\' : undefined"')
const ordinaryRows = app.slice(ordinaryStart, app.indexOf('</section>', ordinaryStart))
expect(ordinaryRows.match(/aria-label="打开任务详情"/g)).toHaveLength(1)
expect(ordinaryRows).toContain('@click.stop="selectTask(node.task)"><Ellipsis/>')
expect(ordinaryRows).toContain("selectTaskUnlessSwiped(node.task)")
expect(ordinaryRows).not.toContain('task-detail-trigger')
expect(ordinaryRows).not.toContain('selectTask(subtask)')
expect(ordinaryRows).not.toContain('aria-label="删除任务"')
expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"')
expect(ordinaryRows).toContain('restoreTask(node.task)')
expect(ordinaryRows).toContain('purgeTask(node.task)')
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('id="today-tasks-heading"'))
expect(overdue).toContain('selectTaskUnlessSwiped(node.task)')
expect(overdue).not.toContain('aria-label="打开任务详情"')
})
it('keeps the parent due tail immediately before the More detail entry', () => {
expect(app).toContain('</span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span>')
expect(app).not.toContain('@click.stop="selectTask(subtask)"><Ellipsis/>')
expect(css).toContain('.task-detail-trigger:hover{color:var(--text-primary);background:#fff7eb}')
it('keeps the due tail as the final parent-row control and opens details from the task body', () => {
expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"')
expect(app).toContain('<span v-if="node.task.due_at" class="task-tail">')
expect(app).not.toContain('task-detail-trigger')
expect(css).not.toContain('.task-detail-trigger')
})
it('keeps task details on a 12px rhythm with fixed labels and right-aligned controls', () => {
@@ -986,17 +988,21 @@ describe('mobile touch targets', () => {
})
describe('approved habit safety and U2 title hierarchy', () => {
it('uses one page title and continuous settings section headings', () => {
it('uses one page title and continuous settings section headings without slogan strips', () => {
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
for (const title of ['数据', '账户与安全', '登录设备', '活动', '危险操作']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
expect(mvpPanel).not.toContain('class="view-intro"')
for (const title of ['数据', '账户与安全', '登录设备', '活动']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
expect(mvpPanel).not.toContain('<h2>危险操作</h2>')
expect(mvpPanel).toContain('class="settings-sections"')
expect(mvpPanel).not.toContain('class="settings-grid"')
expect(mvpPanel).not.toContain('class="tool-card')
expect(css).toContain('.settings-sections{width:min(100%,760px);')
expect(css).toContain('.settings-row{min-height:56px;')
expect(css).toContain('.backup-preflight .danger-button{min-height:44px}')
expect(css).toContain('.backup-preflight.invalid,.settings-danger{background:#fff2ef;')
expect(css).toContain('.backup-preflight.invalid{background:#fff2ef;')
expect(mvpPanel).toContain("v-if=\"restoreMode==='replace'\" class=\"restore-replace-warning\"")
expect(mvpPanel).toContain('替换恢复会覆盖当前数据,请先导出完整备份。')
})
it('keeps invalid forms visible, disables save, and still shows the reason', () => {