feat: unify cream button system
This commit is contained in:
@@ -643,6 +643,9 @@ async function exportData() {
|
|||||||
catch (reason) { backupError.value = reason instanceof Error ? reason.message : '完整备份导出失败' }
|
catch (reason) { backupError.value = reason instanceof Error ? reason.message : '完整备份导出失败' }
|
||||||
finally { backupBusy.value = false }
|
finally { backupBusy.value = false }
|
||||||
}
|
}
|
||||||
|
function openRestoreFilePicker() {
|
||||||
|
if (!backupBusy.value) restoreInput.value?.click()
|
||||||
|
}
|
||||||
function selectRestoreFile(event: Event) {
|
function selectRestoreFile(event: Event) {
|
||||||
cancelPreflight()
|
cancelPreflight()
|
||||||
restoreFile.value = (event.target as HTMLInputElement).files?.[0] ?? null
|
restoreFile.value = (event.target as HTMLInputElement).files?.[0] ?? null
|
||||||
@@ -841,7 +844,7 @@ onBeforeUnmount(() => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="settings-sections">
|
<div class="settings-sections">
|
||||||
<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>
|
<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>
|
||||||
<section class="settings-group settings-data"><header><h2>数据与恢复</h2><small>完整备份</small></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 settings-data"><header><h2>数据与恢复</h2><small>完整备份</small></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><button type="button" class="file-button" :disabled="backupBusy" @click="openRestoreFilePicker"><ArchiveRestore />选择文件</button><input ref="restoreInput" class="restore-file-input" type="file" tabindex="-1" aria-hidden="true" accept=".zip,.csv,.json,application/zip,application/json,text/csv" :disabled="backupBusy" @change="selectRestoreFile"></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></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></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><small>{{ sessionsState === 'success' ? `${sessions.length} 台` : '状态' }}</small></header><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><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="sessionsState==='loading'" class="settings-empty" role="status">正在加载登录设备…</p><div v-else-if="sessionsState==='error'" class="settings-status-error" role="alert"><span>登录设备加载失败:{{ sessionsError }}</span><button type="button" class="soft-button" @click="loadSettings">重试</button></div><p v-else-if="!sessions.length" class="settings-empty">没有可显示的会话。</p></section>
|
<section class="settings-group"><header><h2>登录设备</h2><small>{{ sessionsState === 'success' ? `${sessions.length} 台` : '状态' }}</small></header><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><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="sessionsState==='loading'" class="settings-empty" role="status">正在加载登录设备…</p><div v-else-if="sessionsState==='error'" class="settings-status-error" role="alert"><span>登录设备加载失败:{{ sessionsError }}</span><button type="button" class="soft-button" @click="loadSettings">重试</button></div><p v-else-if="!sessions.length" class="settings-empty">没有可显示的会话。</p></section>
|
||||||
<section class="settings-group"><header><h2>活动记录</h2><small>最近操作</small></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="auditState==='loading'" class="settings-empty" role="status">正在加载活动记录…</p><div v-else-if="auditState==='error'" class="settings-status-error" role="alert"><span>活动记录加载失败:{{ auditError }}</span><button type="button" class="soft-button" @click="loadSettings">重试</button></div><p v-else-if="!audit.length" class="settings-empty">暂无活动记录。</p></section>
|
<section class="settings-group"><header><h2>活动记录</h2><small>最近操作</small></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="auditState==='loading'" class="settings-empty" role="status">正在加载活动记录…</p><div v-else-if="auditState==='error'" class="settings-status-error" role="alert"><span>活动记录加载失败:{{ auditError }}</span><button type="button" class="soft-button" @click="loadSettings">重试</button></div><p v-else-if="!audit.length" class="settings-empty">暂无活动记录。</p></section>
|
||||||
|
|||||||
+22
-4
File diff suppressed because one or more lines are too long
@@ -76,6 +76,43 @@ describe('unified task due display', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('approved cream solid button system', () => {
|
||||||
|
it('gives semantic action buttons one consistent 44px cream material contract', () => {
|
||||||
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:0 17px;border-radius:10px;font-size:13px;font-weight:700;line-height:1;white-space:nowrap;transition:transform .14s ease,background-color .14s ease,border-color .14s ease,box-shadow .14s ease,color .14s ease}')
|
||||||
|
expect(css).toContain(':is(.primary,.primary-small){border:1px solid var(--accent);background:var(--accent);color:#fff;box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
|
||||||
|
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button){border:1px solid #ddd0c0;background:#fffaf3;color:#51483f;box-shadow:inset 0 1px 0 #fff,0 2px 5px rgba(83,62,42,.06)}')
|
||||||
|
expect(css).toContain('.danger-button{border:1px solid #e2b7ac;background:#fff8f5;color:#b83c2c;box-shadow:inset 0 1px 0 #fff,0 2px 5px rgba(105,48,34,.05)}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('provides restrained interaction, focus, and disabled states without resizing buttons', () => {
|
||||||
|
expect(css).toContain(':is(.primary,.primary-small):hover:not(:disabled){background:#dd4b1e;border-color:#dd4b1e}')
|
||||||
|
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button):hover:not(:disabled):not(.disabled){background:#f8eddf;border-color:#d5c5b2}')
|
||||||
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):active:not(:disabled):not(.disabled){transform:translateY(1px) scale(.985)}')
|
||||||
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):focus-visible{outline:3px solid rgba(241,90,41,.24);outline-offset:3px}')
|
||||||
|
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):disabled,.file-button.disabled{background:#ebe2d8;border-color:#ded3c7;color:#aaa095;box-shadow:none;cursor:not-allowed;opacity:1}')
|
||||||
|
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button:disabled,.backup-preflight .danger-button:disabled,.password-form .primary-small:disabled){background:#ebe2d8;border-color:#ded3c7;color:#aaa095;box-shadow:none;opacity:1}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a real disabled file-picker button and keeps its input out of layout', () => {
|
||||||
|
expect(mvpPanel).toContain('function openRestoreFilePicker()')
|
||||||
|
expect(mvpPanel).toContain('<button type="button" class="file-button" :disabled="backupBusy" @click="openRestoreFilePicker"><ArchiveRestore />选择文件</button>')
|
||||||
|
expect(mvpPanel).toContain('<input ref="restoreInput" class="restore-file-input" type="file" tabindex="-1" aria-hidden="true"')
|
||||||
|
expect(css).toContain('.restore-file-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}')
|
||||||
|
expect(mvpPanel).not.toContain('<label class="file-button"')
|
||||||
|
expect(css).not.toContain('.task-compose-sheet button:disabled{opacity:.45')
|
||||||
|
expect(css).not.toContain('.pager button:disabled{opacity:.4')
|
||||||
|
expect(css).not.toContain('.purge-list-dialog button:disabled{opacity:.62')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps special-purpose controls outside the semantic action material', () => {
|
||||||
|
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;')
|
||||||
|
expect(css).toContain('width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent)')
|
||||||
|
expect(css).toContain('.calendar-picker__header button{width:44px;height:44px}')
|
||||||
|
expect(css).toContain('.calendar-picker__grid button{min-height:44px}')
|
||||||
|
expect(css).not.toContain('button:not(')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('mobile navigation styles', () => {
|
describe('mobile navigation styles', () => {
|
||||||
it('renames the bottom More tab to a direct Settings tab', () => {
|
it('renames the bottom More tab to a direct Settings tab', () => {
|
||||||
expect(app).not.toContain('aria-controls="mobile-more-menu"')
|
expect(app).not.toContain('aria-controls="mobile-more-menu"')
|
||||||
@@ -143,8 +180,8 @@ describe('approved Settings 01 paper ledger', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps controls and password errors aligned with the approved form contract', () => {
|
it('keeps controls and password errors aligned with the approved form contract', () => {
|
||||||
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button){min-height:44px;border-radius:9px;border-color:#dcd2c4;background:#fffdf9}')
|
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button){min-height:44px;border-radius:10px}')
|
||||||
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button,.backup-preflight .danger-button,.password-form .primary-small){background:#f15a29;border-color:#f15a29;color:#fff}')
|
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button,.backup-preflight .danger-button,.password-form .primary-small){background:#f15a29;border-color:#f15a29;color:#fff;box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
|
||||||
expect(css).toContain('main:has(>.mvp-view .settings-sections) .danger-text{min-height:44px;border:0;background:transparent;color:var(--danger)}')
|
expect(css).toContain('main:has(>.mvp-view .settings-sections) .danger-text{min-height:44px;border:0;background:transparent;color:var(--danger)}')
|
||||||
expect(css).toContain('.password-form.settings-form{width:100%;display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:10px}')
|
expect(css).toContain('.password-form.settings-form{width:100%;display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:10px}')
|
||||||
expect(css).toContain('.password-form.settings-form .inline-error{grid-column:1/-1;margin:0}')
|
expect(css).toContain('.password-form.settings-form .inline-error{grid-column:1/-1;margin:0}')
|
||||||
|
|||||||
Reference in New Issue
Block a user