Files
dodo/frontend/src/MvpPanel.vue
T
bboysoul 798725ad29
ci / gitleaks (push) Successful in 18s
ci / docker (push) Successful in 3m49s
fix: persist completed item visibility
2026-09-08 11:24:39 +08:00

444 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogOut, Trash2, X } from 'lucide-vue-next'
import { moveItemWithinScope } from './lib/task-utils'
import { dateKey, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, readStoredBoolean, shouldToggleRowSwipe, writeHabitGridCache, writeStoredBoolean } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
type View = 'habits' | 'today-habits' | 'settings'
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; unit?: string; cells?: Array<{ day: string; scheduled?:boolean; paused?:boolean; value: number | boolean }>; stats?: Record<string, number> }
type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string }
const props = defineProps<{ view: View }>()
const emit = defineEmits<{ changed: []; notice: [message: string] }>()
const habits = ref<Habit[]>([])
const sessions = ref<Session[]>([])
const audit = ref<any[]>([])
const busy = ref(false)
const error = ref('')
const habitName = ref('')
const habitType = ref<'boolean' | 'numeric'>('boolean')
const habitTarget = ref(1)
const habitComposerOpen = ref(false)
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)
const restoreFile = ref<File | null>(null)
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const passwordBusy = ref(false)
const passwordError = ref('')
const todayKey = ref(dateKey(new Date()))
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const habitPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
const habitSwipeOffsets = ref<Record<string, number>>({})
const habitReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
const habitReorderTarget = ref('')
const HIDE_COMPLETED_HABITS_STORAGE_KEY = 'dodo.hide-completed-habits'
const hideCompletedHabits = ref(readStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, false))
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
const visibleTodayHabits = computed(() => hideCompletedHabits.value ? todayHabits.value.filter((item) => !isDone(item, todayKey.value)) : todayHabits.value)
const visibleHabits = computed(() => hideCompletedHabits.value ? habits.value.filter((item) => !isDone(item, todayKey.value)) : habits.value)
watch(hideCompletedHabits, (value) => writeStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, value))
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
function formatErrorMessage(detail: unknown): string {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const text = detail
.map((item) => (item && typeof item === 'object' && 'msg' in item && typeof (item as { msg?: unknown }).msg === 'string' ? (item as { msg: string }).msg : String(item)))
.join('')
return text || '请求参数有误'
}
if (detail && typeof detail === 'object') {
const detailObject = detail as Record<string, unknown>
if ('detail' in detailObject) return formatErrorMessage(detailObject.detail)
return JSON.stringify(detail)
}
return '请求失败'
}
async function request(path: string, options: RequestInit = {}) {
const headers: Record<string, string> = { ...(options.headers as Record<string, string> || {}) }
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
const csrf = csrfHeader(options.method)
if (csrf['x-csrf-token']) headers['x-csrf-token'] = csrf['x-csrf-token']
const response = await fetch('/api/v1' + path, { credentials: 'include', ...options, headers })
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(formatErrorMessage((body as { detail?: unknown }).detail))
}
const type = response.headers.get('content-type') || ''
return response.status === 204 ? null : type.includes('json') ? response.json() : response.blob()
}
async function safe(work: () => Promise<void>) {
busy.value = true; error.value = ''
try { await work() } catch (e) { error.value = e instanceof Error ? e.message : '请求失败' } finally { busy.value = false }
}
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
function isDone(h: Habit, day: string) { return isHabitComplete(h.kind, logFor(h, day)?.value, h.target ?? 1) }
function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
}
function startHabitReorder(h: Habit, event: PointerEvent) {
if (busy.value || hideCompletedHabits.value) return
habitReorder.value = { id: h.id, startY: event.clientY, offsetY: 0 }
habitReorderTarget.value = h.id
try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ }
}
function moveHabitReorder(h: Habit, event: PointerEvent) {
const drag = habitReorder.value
if (!drag || drag.id !== h.id) return
drag.offsetY = event.clientY - drag.startY
const handle = event.currentTarget as HTMLElement
const row = document.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-habit-id]'))
.find((element) => element && element !== handle.closest('[data-habit-id]'))
if (row?.dataset.habitId) habitReorderTarget.value = row.dataset.habitId
}
async function finishHabitReorder(h: Habit, event: PointerEvent) {
const drag = habitReorder.value
const targetId = habitReorderTarget.value
habitReorder.value = null
habitReorderTarget.value = ''
if (!drag || drag.id !== h.id || !targetId || targetId === h.id) return
const placement = event.clientY >= drag.startY ? 'after' : 'before'
const previous = habits.value
const next = moveItemWithinScope(previous, h.id, targetId, placement)
if (next === previous) return
habits.value = next
writeHabitGridCache(dateKey(new Date()), next)
try {
await request('/habits/reorder', { method: 'PUT', body: JSON.stringify({ habit_ids: next.map((item) => item.id) }) })
emit('notice', '顺序已保存')
} catch (e) {
habits.value = previous
writeHabitGridCache(dateKey(new Date()), previous)
error.value = e instanceof Error ? e.message : '请求失败'
}
}
function cancelHabitReorder() {
habitReorder.value = null
habitReorderTarget.value = ''
}
function startHabitSwipe(h: Habit, event: TouchEvent) {
if (busy.value || isInteractiveTarget(event.target)) return
const touch = event.touches[0]
if (touch) {
habitSwipeStart.value = { id: h.id, x: touch.clientX, y: touch.clientY }
habitSwipeOffsets.value[h.id] = 0
}
}
function startHabitPointer(h: Habit, event: PointerEvent) {
if (busy.value || isInteractiveTarget(event.target)) return
if (event.pointerType === 'touch') return
habitPointerStart.value = { id: h.id, x: event.clientX, y: event.clientY }
}
function moveHabitPointer(h: Habit, event: PointerEvent) {
const start = habitPointerStart.value
if (!start || start.id !== h.id) return
const deltaX = event.clientX - start.x
const deltaY = event.clientY - start.y
const current = Number(logFor(h, todayKey.value)?.value ?? 0)
if (Math.abs(deltaX) > Math.abs(deltaY)) {
const canIncrement = deltaX > 0 && current < (h.target ?? 1)
const canDecrement = deltaX < 0 && current > 0
if (canIncrement || canDecrement) {
habitSwipeOffsets.value[h.id] = Math.max(-92, Math.min(deltaX, 92))
}
}
}
function moveHabitSwipe(h: Habit, event: TouchEvent) {
const start = habitSwipeStart.value
const touch = event.touches[0]
if (!start || start.id !== h.id || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
const current = Number(logFor(h, todayKey.value)?.value ?? 0)
if (Math.abs(deltaX) > Math.abs(deltaY)) {
const canIncrement = deltaX > 0 && current < (h.target ?? 1)
const canDecrement = deltaX < 0 && current > 0
if (canIncrement || canDecrement) {
habitSwipeOffsets.value[h.id] = Math.max(-92, Math.min(deltaX, 92))
}
}
}
function habitProgressText(h: Habit) {
if (h.kind !== 'numeric') return ''
return `${Number(logFor(h, todayKey.value)?.value ?? 0)} / ${h.target ?? 1}`
}
function setLocalHabitValue(h: Habit, next: number | boolean) {
habits.value = habits.value.map((item) => item.id !== h.id
? item
: {
...item,
cells: (item.cells ?? []).map((cell) => cell.day === todayKey.value ? { ...cell, value: next } : cell),
})
}
async function applyHabitSwipe(h: Habit, deltaX: number) {
const current = logFor(h, todayKey.value)?.value
const next = deltaX > 0
? nextHabitSwipeValue(h.kind, current, h.target ?? 1)
: previousHabitSwipeValue(h.kind, current)
const previous = current ?? 0
setLocalHabitValue(h, next)
try {
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
emit('notice', next > Number(previous) ? '已记录一次 🎉' : '已减少一次')
} catch (e) {
setLocalHabitValue(h, previous)
error.value = e instanceof Error ? e.message : '请求失败'
}
}
async function finishHabitSwipe(h: Habit, event: TouchEvent) {
const start = habitSwipeStart.value
habitSwipeStart.value = null
habitSwipeOffsets.value[h.id] = 0
if (!start || start.id !== h.id || busy.value) return
const touch = event.changedTouches[0]
const deltaX = touch ? touch.clientX - start.x : 0
const deltaY = touch ? touch.clientY - start.y : 0
const current = Number(logFor(h, todayKey.value)?.value ?? 0)
const expectedDirection = h.kind === 'numeric'
? ((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)) {
await applyHabitSwipe(h, deltaX)
}
}
async function finishHabitPointer(h: Habit, event: PointerEvent) {
const start = habitPointerStart.value
if (!start || start.id !== h.id) return
habitPointerStart.value = null
habitSwipeOffsets.value[h.id] = 0
const deltaX = event.clientX - start.x
const deltaY = event.clientY - start.y
const current = Number(logFor(h, todayKey.value)?.value ?? 0)
const expectedDirection = h.kind === 'numeric'
? ((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)) {
void applyHabitSwipe(h, deltaX)
}
}
function cancelHabitPointer(h?: Habit) {
habitPointerStart.value = null
if (h) habitSwipeOffsets.value[h.id] = 0
}
function cancelHabitSwipe(h?: Habit) {
habitSwipeStart.value = null
if (h) habitSwipeOffsets.value[h.id] = 0
}
async function toggleHabitFromButton(h: Habit) {
const current = logFor(h, todayKey.value)?.value
const previous = current ?? 0
const next = habitButtonValue(h.kind, current, h.target ?? 1)
setLocalHabitValue(h, next)
try {
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
emit('notice', habitButtonNotice(h.kind, previous, next))
} catch (e) {
setLocalHabitValue(h, previous)
error.value = e instanceof Error ? e.message : '请求失败'
}
}
async function addHabit() {
if (!habitName.value.trim()) return
await safe(async () => {
await request('/habits', { method: 'POST', body: JSON.stringify({ name: habitName.value.trim(), kind: habitType.value, target: habitTarget.value, schedule_type: 'daily' }) })
habitName.value = ''
habitComposerOpen.value = false
await loadHabits()
emit('notice', '习惯已创建')
})
}
function openHabitComposer(origin?: { x: number; y: number }) {
if (origin) habitComposeOrigin.value = origin
habitName.value = ''
habitType.value = 'boolean'
habitTarget.value = 1
habitComposerOpen.value = true
void nextTick(() => habitNameInput.value?.focus())
}
function closeHabitComposer() { habitComposerOpen.value = false }
defineExpose({ openHabitComposer })
async function archiveHabit(h: Habit) {
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
await safe(async () => {
await request(`/habits/${h.id}`, { method: 'DELETE' })
await loadHabits()
emit('notice', '习惯已归档')
})
}
function refreshHabitDay() {
const next = dateKey(new Date())
if (next !== todayKey.value) {
todayKey.value = next
if (props.view === 'habits') void loadHabits()
}
}
async function loadHabits() {
const week = dateKey(new Date())
const cached = readHabitGridCache<Habit>(week)
if (cached) habits.value = cached
if (!cached) busy.value = true
error.value = ''
try {
const data = await request(`/habits/grid?week=${week}`) as { habits?: Habit[] }
habits.value = data.habits ?? []
writeHabitGridCache(week, habits.value)
} catch (e) {
error.value = e instanceof Error ? e.message : '请求失败'
} finally {
if (!cached) busy.value = false
}
}
async function loadSettings() {
await safe(async () => {
const [s, a] = await Promise.all([request('/sessions').catch(() => []), request('/audit-logs?limit=20').catch(() => [])])
sessions.value = mergePage<Session>(s).items
audit.value = mergePage<any>(a).items
})
}
async function revoke(id: string) {
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
}
function downloadBlob(blob: Blob, name: string) {
const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
}
async function exportData() {
await safe(async () => {
const response = await fetch('/api/v1/export.csv', { credentials: 'include' })
if (!response.ok) throw new Error('导出失败')
downloadBlob(await response.blob(), 'dodo-export.csv')
})
}
async function restore() {
if (!restoreFile.value) return
if (!confirm('恢复为合并模式,将导入备份中的清单与任务。继续吗?')) return
await safe(async () => {
if (restoreFile.value!.name.toLowerCase().endsWith('.csv')) {
const form = new FormData()
form.append('file', restoreFile.value!)
await request('/restore.csv?mode=merge', { method: 'POST', body: form })
} else {
const text = await restoreFile.value!.text()
await request('/restore?mode=merge', { method: 'POST', body: text })
}
emit('changed'); emit('notice', '数据已恢复')
})
}
async function changePassword() {
passwordError.value = ''
if (newPassword.value !== confirmPassword.value) {
passwordError.value = '两次输入的新密码不一致'
return
}
if (newPassword.value.length < 12) {
passwordError.value = '新密码至少需要 12 位'
return
}
passwordBusy.value = true
try {
await request('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword.value, new_password: newPassword.value }),
})
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
emit('notice', '密码已修改,其他设备已退出登录')
await loadSettings()
} catch (e) {
passwordError.value = e instanceof Error ? e.message : '修改密码失败'
} finally {
passwordBusy.value = false
}
}
onMounted(() => {
if (props.view === 'habits' || props.view === 'today-habits') {
refreshHabitDay()
void loadHabits()
dayRolloverTimer = setInterval(refreshHabitDay, 60_000)
} else {
void loadSettings()
}
})
onBeforeUnmount(() => {
if (dayRolloverTimer) clearInterval(dayRolloverTimer)
})
</script>
<template>
<section class="mvp-view" :class="{ loading: busy }">
<p v-if="error" class="inline-error">{{ error }}</p>
<!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 -->
<template v-if="view === 'habits' || view === 'today-habits'">
<header v-if="view === 'habits'" class="view-intro">
<div><small>把想坚持的事变成每天的日常</small><h2>习惯</h2></div>
<div class="habit-toolbar"><label><input v-model="hideCompletedHabits" type="checkbox"> 隐藏已完成</label></div>
</header>
<!-- 今日习惯只展示今天该做的习惯复用正式习惯行样式 -->
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
<div class="habit-toolbar habit-toolbar-today"><label><input v-model="hideCompletedHabits" type="checkbox"> 隐藏已完成</label></div>
<article v-for="h in visibleTodayHabits" :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">
<span class="habit-name">{{ h.name }}</span>
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
</div>
</article>
<div v-if="!visibleTodayHabits.length && !busy" class="empty-panel today-empty-panel"><span>{{ hideCompletedHabits && todayHabits.length ? '已完成的习惯已隐藏。' : '今天没有安排习惯,轻松一下吧。' }}</span><button v-if="!hideCompletedHabits || !todayHabits.length" class="soft-button empty-action" @click="openHabitComposer"><Check/>添加习惯</button></div>
</div>
<!-- 完整习惯列表 -->
<Transition name="task-compose">
<div v-if="habitComposerOpen" class="task-compose-mask" @click.self="closeHabitComposer">
<form class="task-compose-sheet habit-compose-sheet" :style="habitComposeStyle" role="dialog" aria-modal="true" aria-labelledby="habit-compose-title" @submit.prevent="addHabit" @keydown.esc="closeHabitComposer">
<header><div><small>NEW HABIT</small><h2 id="habit-compose-title">添加习惯</h2></div><button class="icon" type="button" aria-label="关闭添加习惯" @click="closeHabitComposer"><X /></button></header>
<label>习惯名称<input ref="habitNameInput" v-model="habitName" placeholder="例如:每天喝水 8 杯" aria-label="新习惯名称"></label>
<div class="task-compose-row"><label>记录方式<select v-model="habitType" aria-label="习惯类型"><option value="boolean">完成 / 未完成</option><option value="numeric">按数量记录</option></select></label><label v-if="habitType === 'numeric'">目标值<input v-model.number="habitTarget" type="number" min="0" step="any" aria-label="目标值"></label></div>
<footer><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="!habitName.trim()">添加习惯</button></footer>
</form>
</div>
</Transition>
<!-- 习惯列表支持整行滑动记录 -->
<div v-if="view === 'habits'" class="habit-list">
<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">
<span class="habit-name">{{ h.name }}</span>
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
</div>
<button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button>
</article>
<div v-if="!habits.length && !busy" class="empty-panel">还没有习惯从一件容易坚持的小事开始</div>
</div>
</template>
<!-- 设置与数据 -->
<template v-else>
<header class="view-intro">
<div><small>备份迁移与安全</small><h2>设置与数据</h2></div>
</header>
<div class="settings-grid">
<article class="tool-card"><FileJson /><h3>数据导出与恢复</h3><p>导出完整 CSV 数据或从 CSV / JSON 备份恢复</p><button class="soft-button" @click="exportData"><Download />导出 CSV</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept=".csv,application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
<article class="tool-card password-card"><Activity /><h3>修改密码</h3><p>修改后当前设备保持登录其他设备会自动退出</p><form class="password-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></article>
<article class="tool-card wide"><LogOut /><h3>登录会话</h3><div v-for="s in sessions" :key="s.id" class="session-row"><span><b>{{ s.current ? '当前设备' : '其他设备' }}</b><small>{{ s.user_agent || '未知设备' }} · {{ s.last_seen_at || s.created_at }}</small></span><button v-if="!s.current" class="danger-text" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话</p></article>
<article v-if="audit.length" class="tool-card wide"><Activity /><h3>最近活动</h3><div v-for="(row, i) in audit" :key="row.id || i" class="audit-row"><span>{{ row.action || row.event || '变更' }}</span><small>{{ row.created_at || row.timestamp }}</small></div></article>
</div>
</template>
</section>
</template>