This commit is contained in:
+191
-80
@@ -1,23 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import type { CalendarOptions, EventDropArg } from '@fullcalendar/core'
|
||||
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
|
||||
import { dateKey, habitWeek, mergePage, moveDueDate } from './lib/mvp-utils'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Activity, ArchiveRestore, Check, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
|
||||
import { dateKey, isHabitComplete, mergePage, numericHabitInputValue } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
|
||||
type View = 'calendar'|'habits'|'settings'
|
||||
type Task = { id:string; title:string; due_at:string|null; version:number; recurrence_id?:string|null }
|
||||
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[]>([]), calendarTasks = ref<Task[]>([]), sessions = ref<Session[]>([]), audit = ref<any[]>([])
|
||||
const busy = ref(false), error = ref(''), habitName = ref(''), habitType = ref('boolean'), habitTarget = ref(1)
|
||||
const importFile = ref<File|null>(null), importPreview = ref<any>(null), restoreFile = ref<File|null>(null)
|
||||
const week = computed(() => habitWeek())
|
||||
type View = '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 importFile = ref<File | null>(null)
|
||||
const importPreview = ref<any>(null)
|
||||
const restoreFile = ref<File | null>(null)
|
||||
const numericValues = ref<Record<string, number>>({})
|
||||
const todayKey = ref(dateKey(new Date()))
|
||||
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function formatErrorMessage(detail: unknown): string {
|
||||
if (typeof detail === 'string') return detail
|
||||
@@ -35,78 +40,184 @@ function formatErrorMessage(detail: unknown): string {
|
||||
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'
|
||||
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 })
|
||||
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()
|
||||
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} }
|
||||
async function loadHabits(){ await safe(async()=>{const grid=await request(`/habits/grid?week=${dateKey(week.value[0])}`); habits.value=(grid as {habits?: Habit[]}).habits ?? [];}) }
|
||||
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='';await loadHabits();emit('notice','习惯已创建')})}
|
||||
function logFor(h:Habit,day:string){return (h.cells??[]).find(c=>c.day===day)}
|
||||
async function checkIn(h:Habit,day:string,value?:number){await safe(async()=>{const current=logFor(h,day);const next=h.kind==='numeric'?(value??h.target??1):(current?.value?0:1);await request(`/habits/${h.id}/logs/${day}`,{method:'PUT',body:JSON.stringify({value:next})});await loadHabits();emit('notice','打卡已记录')})}
|
||||
async function deleteHabit(h:Habit){if(!confirm(`删除习惯“${h.name}”?`))return;await safe(async()=>{await request(`/habits/${h.id}`,{method:'DELETE'});await loadHabits()})}
|
||||
const currentCalendarRange = ref<{ startStr:string; endStr:string }|null>(null)
|
||||
async function loadCalendar(range?: { startStr?: string; endStr?: string }) {
|
||||
if (range?.startStr && range?.endStr) currentCalendarRange.value = { startStr: range.startStr, endStr: range.endStr }
|
||||
const now = new Date()
|
||||
const activeRange = currentCalendarRange.value
|
||||
const start = activeRange?.startStr.slice(0, 10) ?? dateKey(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||
const endExclusiveKey = activeRange?.endStr.slice(0, 10) ?? dateKey(new Date(now.getFullYear(), now.getMonth() + 1, 1))
|
||||
const endExclusive = new Date(`${endExclusiveKey}T00:00:00`)
|
||||
endExclusive.setDate(endExclusive.getDate() - 1)
|
||||
const end = dateKey(endExclusive)
|
||||
await safe(async()=>{
|
||||
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
const rows = await request(`/calendar?start=${start}&end=${end}&timezone=${encodeURIComponent(tzName)}`) as Array<{id?:string;task_id:string;recurrence_id?:string|null;title:string;due_at?:string;occurrence_at:string;version?:number}>
|
||||
calendarTasks.value = rows.map(row=>({
|
||||
id: row.recurrence_id ? `${row.task_id}:${row.occurrence_at}` : (row.id ?? row.task_id),
|
||||
title: row.title,
|
||||
due_at: row.due_at ?? row.occurrence_at,
|
||||
version: row.version ?? 0,
|
||||
recurrence_id: row.recurrence_id,
|
||||
}))
|
||||
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) }
|
||||
async function toggleHabit(h: Habit, day: string) {
|
||||
if (busy.value) return
|
||||
const current = logFor(h, day)
|
||||
const next = current?.value ? 0 : 1
|
||||
await safe(async () => {
|
||||
await request(`/habits/${h.id}/logs/${day}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
|
||||
await loadHabits()
|
||||
emit('notice', next ? '打卡成功 🎉' : '已取消打卡')
|
||||
})
|
||||
}
|
||||
async function moveTask(arg:EventDropArg){const task=calendarTasks.value.find(t=>t.id===arg.event.id);if(!task||task.version===0)return;const previous=task.due_at;try{await request(`/tasks/${task.id}`,{method:'PATCH',body:JSON.stringify({due_at:moveDueDate(previous,arg.event.startStr.slice(0,10)),version:task.version})});await loadCalendar();emit('notice','日期已更新')}catch(e){arg.revert();error.value=e instanceof Error?e.message:'移动失败'}}
|
||||
const calendarOptions=computed<CalendarOptions>(()=>({plugins:[dayGridPlugin,interactionPlugin],initialView:'dayGridMonth',locale:'zh-cn',firstDay:1,height:'auto',editable:true,dayMaxEvents:4,headerToolbar:{left:'prev,next today',center:'title',right:''},events:calendarTasks.value.filter(t=>t.due_at).map(t=>({id:t.id,title:t.title,start:t.due_at!,editable:t.version!==0})),datesSet:loadCalendar,eventDrop:moveTask}))
|
||||
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),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
async function exportData(){await safe(async()=>downloadBlob(await request('/export'),'dodo-export.json'))}
|
||||
async function previewImport(){if(!importFile.value)return;await safe(async()=>{const form=new FormData();form.append('file',importFile.value!);importPreview.value=await request('/import/ticktick/preview',{method:'POST',body:form})})}
|
||||
async function confirmImport(){await safe(async()=>{const form=new FormData();form.append('file',importFile.value!);const result=await request('/import/ticktick',{method:'POST',body:form});importPreview.value=null;emit('changed');emit('notice',`导入完成:新增 ${result?.imported??0},跳过 ${result?.skipped??0}`)})}
|
||||
async function restore(){if(!restoreFile.value)return;if(!confirm('恢复为合并模式,将导入 JSON 中的清单与任务。继续吗?'))return;await safe(async()=>{const text=await restoreFile.value!.text();await request('/restore?mode=merge',{method:'POST',body:text});emit('changed');emit('notice','数据已恢复')})}
|
||||
onMounted(()=>props.view==='habits'?loadHabits():props.view==='settings'?loadSettings():undefined)
|
||||
async function recordNumeric(h: Habit, day: string) {
|
||||
if (busy.value) return
|
||||
const next = numericHabitInputValue(numericValues.value[h.id])
|
||||
if (next === null || (h.max_value != null && next > h.max_value)) return
|
||||
await safe(async () => {
|
||||
await request(`/habits/${h.id}/logs/${day}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
|
||||
await loadHabits()
|
||||
emit('notice', next > 0 ? '已记录 🎉' : '已清零')
|
||||
})
|
||||
}
|
||||
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 = ''
|
||||
await loadHabits()
|
||||
emit('notice', '习惯已创建')
|
||||
})
|
||||
}
|
||||
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() {
|
||||
await safe(async () => {
|
||||
const data = await request(`/habits/grid?week=${dateKey(new Date())}`) as { habits?: Habit[] }
|
||||
habits.value = data.habits ?? []
|
||||
})
|
||||
}
|
||||
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 () => downloadBlob(await request('/export') as Blob, 'dodo-export.json')) }
|
||||
async function previewImport() {
|
||||
if (!importFile.value) return
|
||||
await safe(async () => {
|
||||
const form = new FormData(); form.append('file', importFile.value!)
|
||||
importPreview.value = await request('/import/ticktick/preview', { method: 'POST', body: form })
|
||||
})
|
||||
}
|
||||
async function confirmImport() {
|
||||
await safe(async () => {
|
||||
const form = new FormData(); form.append('file', importFile.value!)
|
||||
const result = await request('/import/ticktick', { method: 'POST', body: form }) as { imported?: number; skipped?: number }
|
||||
importPreview.value = null
|
||||
emit('changed'); emit('notice', `导入完成:新增 ${result?.imported ?? 0},跳过 ${result?.skipped ?? 0}`)
|
||||
})
|
||||
}
|
||||
async function restore() {
|
||||
if (!restoreFile.value) return
|
||||
if (!confirm('恢复为合并模式,将导入 JSON 中的清单与任务。继续吗?')) return
|
||||
await safe(async () => {
|
||||
const text = await restoreFile.value!.text()
|
||||
await request('/restore?mode=merge', { method: 'POST', body: text })
|
||||
emit('changed'); emit('notice', '数据已恢复')
|
||||
})
|
||||
}
|
||||
onMounted(() => {
|
||||
if (props.view === '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>
|
||||
<template v-if="view==='calendar'">
|
||||
<header class="view-intro"><div><small>拖动任务即可改期</small><h2>月历</h2></div><span>{{calendarTasks.length}} 个已排期任务</span></header>
|
||||
<div class="calendar-card"><FullCalendar :options="calendarOptions" /></div>
|
||||
</template>
|
||||
<template v-else-if="view==='habits'">
|
||||
<header class="view-intro"><div><small>今天做一点,明天更轻松</small><h2>习惯</h2></div><button class="soft-button" @click="loadHabits"><RefreshCw/>刷新</button></header>
|
||||
<form class="habit-create" @submit.prevent="addHabit"><input v-model="habitName" placeholder="新习惯名称"><select v-model="habitType"><option value="boolean">完成 / 未完成</option><option value="numeric">数值</option></select><input v-if="habitType==='numeric'" v-model.number="habitTarget" type="number" min="0" step="any" aria-label="目标值"><button><Plus/>添加</button></form>
|
||||
<div class="habit-list"><article v-for="h in habits" :key="h.id" class="habit-card"><div class="habit-title"><div><h3>{{h.name}}</h3><small v-if="h.stats">连续 {{h.stats.current_streak??0}} 天 · 完成率 {{Math.round((h.stats.completion_rate??0)*100)}}%</small></div><button class="icon ghost" aria-label="删除习惯" @click="deleteHabit(h)"><Trash2/></button></div><div class="week-grid"><div v-for="d in week" :key="dateKey(d)"><small>{{['一','二','三','四','五','六','日'][(d.getDay()+6)%7]}}<br>{{d.getDate()}}</small><button v-if="h.kind!=='numeric'" class="habit-check" :class="{done:logFor(h,dateKey(d))?.value}" @click="checkIn(h,dateKey(d))">{{logFor(h,dateKey(d))?.value?'✓':'·'}}</button><input v-else type="number" :value="logFor(h,dateKey(d))?.value??''" :placeholder="String(h.target??1)" @change="checkIn(h,dateKey(d),Number(($event.target as HTMLInputElement).value))"></div></div></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>下载完整 JSON 备份,或从备份恢复。</p><button class="soft-button" @click="exportData"><Download/>导出 JSON</button><label class="file-button"><ArchiveRestore/>选择备份<input type="file" accept="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"><Upload/><h3>导入</h3><p>先预览变化,确认后才写入。</p><label class="file-button">选择文件<input type="file" accept=".json,.csv" @change="importFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button :disabled="!importFile" class="soft-button" @click="previewImport">生成预览</button><pre v-if="importPreview">{{JSON.stringify(importPreview,null,2)}}</pre><button v-if="importPreview" class="primary-small" @click="confirmImport">确认导入</button></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>
|
||||
<section class="mvp-view" :class="{ loading: busy }">
|
||||
<p v-if="error" class="inline-error">{{ error }}</p>
|
||||
|
||||
<!-- 习惯(TickTick 风格:一次只操作一个习惯,不再逐格小按钮误触) -->
|
||||
<template v-if="view === 'habits'">
|
||||
<header class="view-intro">
|
||||
<div><small>把想坚持的事,变成每天的日常</small><h2>习惯</h2></div>
|
||||
<button class="soft-button" @click="loadHabits"><RefreshCw />刷新</button>
|
||||
</header>
|
||||
|
||||
<!-- 新建习惯 -->
|
||||
<form class="habit-create" @submit.prevent="addHabit">
|
||||
<input v-model="habitName" placeholder="新习惯名称(如:喝水 8 杯)" aria-label="新习惯名称">
|
||||
<select v-model="habitType" aria-label="习惯类型">
|
||||
<option value="boolean">完成 / 未完成</option>
|
||||
<option value="numeric">按数量记录</option>
|
||||
</select>
|
||||
<input v-if="habitType === 'numeric'" v-model.number="habitTarget" type="number" min="0" step="any" placeholder="目标值" aria-label="目标值">
|
||||
<button class="primary-small"><Plus />添加</button>
|
||||
</form>
|
||||
|
||||
|
||||
<!-- 习惯列表:展示和操作分离,只点右侧明确按钮,降低误触 -->
|
||||
<div class="habit-list">
|
||||
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey) }">
|
||||
<div v-if="h.kind !== 'numeric'" class="habit-main">
|
||||
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已打卡' : '今天还没做' }}</small></span>
|
||||
<button class="habit-check-button" :class="{ done: isDone(h, todayKey) }" :aria-label="isDone(h, todayKey) ? `取消${h.name}今天的打卡` : `完成${h.name}今天的打卡`" :aria-pressed="isDone(h, todayKey)" @click="toggleHabit(h, todayKey)">
|
||||
<Check v-if="isDone(h, todayKey)" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="habit-main numeric-habit">
|
||||
<span><span class="habit-name">{{ h.name }}</span><small>今天 {{ logFor(h, todayKey)?.value || 0 }} / {{ h.target || 1 }}{{ h.unit || '' }}</small></span>
|
||||
<span class="numeric-action">
|
||||
<input v-model.number="numericValues[h.id]" type="number" min="0" :max="h.max_value ?? undefined" step="any" :placeholder="String(h.target || 1)" :aria-label="`${h.name}今日数值`">
|
||||
<button class="soft-button" @click="recordNumeric(h, todayKey)">记录</button>
|
||||
</span>
|
||||
</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>下载完整 JSON 备份,或从备份恢复。</p><button class="soft-button" @click="exportData"><Download />导出 JSON</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept="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"><Upload /><h3>导入</h3><p>先预览变化,确认后才写入。</p><label class="file-button">选择文件<input type="file" accept=".json,.csv" @change="importFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button :disabled="!importFile" class="soft-button" @click="previewImport">生成预览</button><pre v-if="importPreview">{{ JSON.stringify(importPreview, null, 2) }}</pre><button v-if="importPreview" class="primary-small" @click="confirmImport">确认导入</button></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>
|
||||
|
||||
Reference in New Issue
Block a user