mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
feat(admin): improve record management
This commit is contained in:
@@ -1,27 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
FilterX,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { checkInApi, type CheckInRecord } from '@/api'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import { cardClass, inputClass, sectionHeaderClass, toneClass } from '@/components/ui'
|
||||
import { cardClass, inputClass, labelClass, sectionHeaderClass, toneClass } from '@/components/ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
|
||||
import {
|
||||
formatRecordDetailContent,
|
||||
recordResponseSummary,
|
||||
visibleRecordRange,
|
||||
} from './admin-records'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const records = ref<CheckInRecord[]>([])
|
||||
const filters = reactive({ task_id: '', status: '', limit: 50 })
|
||||
const total = ref(0)
|
||||
const selectedRecord = ref<CheckInRecord | null>(null)
|
||||
const filters = reactive({ task_id: '', status: '', trigger_type: '', skip: 0, limit: 50 })
|
||||
|
||||
const rangeLabel = computed(() => visibleRecordRange(total.value, filters.skip, filters.limit))
|
||||
const hasActiveFilters = computed(
|
||||
() => Boolean(filters.task_id || filters.status || filters.trigger_type) || filters.limit !== 50,
|
||||
)
|
||||
const activeFilterLabels = computed(() => {
|
||||
const labels: string[] = []
|
||||
if (filters.task_id) labels.push(`任务 #${filters.task_id}`)
|
||||
if (filters.status) labels.push(`状态:${statusLabel(filters.status)}`)
|
||||
if (filters.trigger_type) labels.push(`触发:${statusLabel(filters.trigger_type)}`)
|
||||
if (filters.limit !== 50) labels.push(`每页 ${filters.limit}`)
|
||||
return labels
|
||||
})
|
||||
const selectedRecordTitle = computed(() => {
|
||||
if (!selectedRecord.value) return '记录详情'
|
||||
return (
|
||||
selectedRecord.value.task_name ||
|
||||
selectedRecord.value.thread_id ||
|
||||
`任务 #${selectedRecord.value.task_id}`
|
||||
)
|
||||
})
|
||||
|
||||
function normalizedParams() {
|
||||
return {
|
||||
skip: filters.skip,
|
||||
limit: filters.limit,
|
||||
task_id: filters.task_id.trim() || undefined,
|
||||
status: filters.status || undefined,
|
||||
trigger_type: filters.trigger_type || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const page = await checkInApi.allRecords({
|
||||
limit: filters.limit,
|
||||
task_id: filters.task_id,
|
||||
status: filters.status,
|
||||
})
|
||||
const page = await checkInApi.allRecords(normalizedParams())
|
||||
records.value = page.records
|
||||
total.value = page.total
|
||||
filters.skip = page.skip
|
||||
filters.limit = page.limit
|
||||
} catch (err) {
|
||||
error.value = extractErrorMessage(err)
|
||||
} finally {
|
||||
@@ -29,29 +75,104 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
filters.skip = 0
|
||||
void load()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.task_id = ''
|
||||
filters.status = ''
|
||||
filters.trigger_type = ''
|
||||
filters.skip = 0
|
||||
filters.limit = 50
|
||||
void load()
|
||||
}
|
||||
|
||||
function page(delta: number) {
|
||||
filters.skip = Math.max(0, filters.skip + delta * filters.limit)
|
||||
void load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="[cardClass, 'overflow-hidden']">
|
||||
<div :class="[sectionHeaderClass, 'lg:grid-cols-[1fr_120px_160px_120px_auto]']">
|
||||
<div>
|
||||
<div :class="sectionHeaderClass">
|
||||
<div class="min-w-0">
|
||||
<h2 class="font-semibold">全量记录</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">共 {{ total }} 条,{{ rangeLabel }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="hasActiveFilters" :class="toneClass('info')">已筛选</span>
|
||||
<Button variant="outline" type="button" @click="load">
|
||||
<RefreshCw class="size-4" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<input v-model="filters.task_id" :class="inputClass" placeholder="任务 ID" />
|
||||
<select v-model="filters.status" :class="inputClass">
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failure">失败</option>
|
||||
<option value="out_of_time">超出时间</option>
|
||||
<option value="token_expired">凭证过期</option>
|
||||
</select>
|
||||
<input v-model.number="filters.limit" :class="inputClass" type="number" min="1" max="200" />
|
||||
<Button variant="outline" type="button" @click="load">
|
||||
<Search class="size-4" />
|
||||
筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 border-b border-border bg-muted/55 p-4">
|
||||
<div
|
||||
class="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_150px_150px_120px_auto_auto] xl:items-end"
|
||||
>
|
||||
<label class="grid gap-2">
|
||||
<span :class="labelClass">任务 ID</span>
|
||||
<input v-model="filters.task_id" :class="inputClass" placeholder="输入任务 ID" />
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span :class="labelClass">状态</span>
|
||||
<select v-model="filters.status" :class="inputClass">
|
||||
<option value="">全部状态</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failure">失败</option>
|
||||
<option value="out_of_time">超出时间</option>
|
||||
<option value="token_expired">凭证过期</option>
|
||||
<option value="pending">进行中</option>
|
||||
<option value="unknown">未知</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span :class="labelClass">触发方式</span>
|
||||
<select v-model="filters.trigger_type" :class="inputClass">
|
||||
<option value="">全部触发</option>
|
||||
<option value="manual">手动</option>
|
||||
<option value="scheduled">定时</option>
|
||||
<option value="scheduler">定时</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-2">
|
||||
<span :class="labelClass">每页</span>
|
||||
<input
|
||||
v-model.number="filters.limit"
|
||||
:class="inputClass"
|
||||
type="number"
|
||||
min="1"
|
||||
max="200"
|
||||
/>
|
||||
</label>
|
||||
<Button variant="outline" type="button" @click="applyFilters">
|
||||
<Search class="size-4" />
|
||||
筛选
|
||||
</Button>
|
||||
<Button variant="ghost" type="button" :disabled="!hasActiveFilters" @click="resetFilters">
|
||||
<FilterX class="size-4" />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="activeFilterLabels.length" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="label in activeFilterLabels"
|
||||
:key="label"
|
||||
class="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StateBlock v-if="loading" title="正在加载记录" type="loading" />
|
||||
<StateBlock
|
||||
v-else-if="error"
|
||||
@@ -61,31 +182,165 @@ onMounted(load)
|
||||
action-label="重试"
|
||||
@action="load"
|
||||
/>
|
||||
<StateBlock v-else-if="records.length === 0" title="暂无记录" />
|
||||
<div v-else class="divide-y divide-border">
|
||||
<article
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="grid gap-3 p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
|
||||
<StateBlock
|
||||
v-else-if="records.length === 0"
|
||||
:title="hasActiveFilters ? '没有匹配记录' : '暂无记录'"
|
||||
:description="hasActiveFilters ? '调整筛选条件或重置后再试' : undefined"
|
||||
:action-label="hasActiveFilters ? '重置筛选' : undefined"
|
||||
@action="resetFilters"
|
||||
/>
|
||||
<div v-else>
|
||||
<div
|
||||
class="hidden border-b border-border bg-muted/35 px-4 py-2 text-xs font-semibold uppercase tracking-normal text-muted-foreground xl:grid xl:grid-cols-[180px_minmax(160px,1.1fr)_120px_minmax(0,1.4fr)_120px] xl:gap-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium">
|
||||
{{ record.user_alias || record.user_email || `任务 #${record.task_id}` }}
|
||||
<span>用户</span>
|
||||
<span>任务</span>
|
||||
<span>状态</span>
|
||||
<span>响应摘要</span>
|
||||
<span class="text-right">操作</span>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<article
|
||||
v-for="record in records"
|
||||
:key="record.id"
|
||||
class="grid gap-3 p-3 sm:p-4 xl:grid-cols-[180px_minmax(160px,1.1fr)_120px_minmax(0,1.4fr)_120px] xl:items-center xl:gap-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold">
|
||||
{{ record.user_alias || record.user_email || `用户 #${record.user_id || '未知'}` }}
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-xs text-muted-foreground">
|
||||
ID {{ record.user_id || 'unknown' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
||||
<span>{{ formatFullDateTime(record.check_in_time) }}</span>
|
||||
<span>{{ record.response_text || record.error_message || '无响应内容' }}</span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">
|
||||
{{ record.task_name || `任务 #${record.task_id}` }}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span class="font-mono">#{{ record.task_id }}</span>
|
||||
<span v-if="record.thread_id" class="font-mono"
|
||||
>ThreadId: {{ record.thread_id }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 xl:block">
|
||||
<span :class="toneClass(statusTone(record.status))">{{
|
||||
statusLabel(record.status)
|
||||
}}</span>
|
||||
<div class="mt-0 text-xs text-muted-foreground xl:mt-2">
|
||||
{{ statusLabel(record.trigger_type) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="line-clamp-2 text-sm leading-5 text-foreground">
|
||||
{{ recordResponseSummary(record) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ formatFullDateTime(record.check_in_time) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-start xl:justify-end">
|
||||
<Button variant="outline" type="button" @click="selectedRecord = record">
|
||||
<Eye class="size-4" />
|
||||
详情
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-t border-border bg-muted/55 px-4 py-3 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>共 {{ total }} 条,{{ rangeLabel }}</span>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" :disabled="filters.skip === 0" type="button" @click="page(-1)">
|
||||
<ChevronLeft class="size-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="filters.skip + filters.limit >= total"
|
||||
type="button"
|
||||
@click="page(1)"
|
||||
>
|
||||
下一页
|
||||
<ChevronRight class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 md:justify-end">
|
||||
<span :class="toneClass(statusTone(record.status))">{{
|
||||
statusLabel(record.status)
|
||||
}}</span>
|
||||
<span class="text-sm text-muted-foreground">{{
|
||||
record.task_name || record.thread_id || '无任务名'
|
||||
}}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog :open="selectedRecord !== null" @update:open="(open) => !open && (selectedRecord = null)">
|
||||
<DialogContent
|
||||
class="grid max-h-[calc(100dvh-2rem)] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0 sm:max-w-[min(760px,calc(100vw-2rem))]"
|
||||
>
|
||||
<DialogHeader class="border-b border-border bg-muted/55 px-5 py-4">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<ClipboardList class="size-5" />
|
||||
{{ selectedRecordTitle }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div v-if="selectedRecord" class="min-h-0 overflow-y-auto p-5">
|
||||
<div class="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-border bg-background p-3">
|
||||
<div :class="labelClass">记录 ID</div>
|
||||
<div class="mt-1 font-mono">{{ selectedRecord.id }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-background p-3">
|
||||
<div :class="labelClass">状态</div>
|
||||
<div class="mt-1 flex flex-wrap gap-2">
|
||||
<span :class="toneClass(statusTone(selectedRecord.status))">{{
|
||||
statusLabel(selectedRecord.status)
|
||||
}}</span>
|
||||
<span :class="toneClass('neutral')">{{
|
||||
statusLabel(selectedRecord.trigger_type)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-background p-3">
|
||||
<div :class="labelClass">用户</div>
|
||||
<div class="mt-1 truncate">
|
||||
{{
|
||||
selectedRecord.user_alias ||
|
||||
selectedRecord.user_email ||
|
||||
`用户 #${selectedRecord.user_id || '未知'}`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-background p-3">
|
||||
<div :class="labelClass">任务</div>
|
||||
<div class="mt-1 truncate">
|
||||
{{ selectedRecord.task_name || `任务 #${selectedRecord.task_id}` }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-background p-3">
|
||||
<div :class="labelClass">ThreadId</div>
|
||||
<div class="mt-1 truncate font-mono">{{ selectedRecord.thread_id || '未记录' }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-background p-3">
|
||||
<div :class="labelClass">打卡时间</div>
|
||||
<div class="mt-1">{{ formatFullDateTime(selectedRecord.check_in_time) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4">
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold">响应内容</h3>
|
||||
<pre
|
||||
class="mt-2 max-h-64 overflow-auto rounded-lg bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-100"
|
||||
>{{ formatRecordDetailContent(selectedRecord.response_text) }}</pre
|
||||
>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold">错误信息</h3>
|
||||
<pre
|
||||
class="mt-2 max-h-64 overflow-auto rounded-lg bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-100"
|
||||
>{{ formatRecordDetailContent(selectedRecord.error_message) }}</pre
|
||||
>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { CheckInRecord } from '@/api'
|
||||
|
||||
export function visibleRecordRange(total: number, skip: number, limit: number) {
|
||||
if (total <= 0) return '当前 0 - 0'
|
||||
const start = Math.min(skip + 1, total)
|
||||
const end = Math.min(skip + limit, total)
|
||||
return `当前 ${start} - ${end}`
|
||||
}
|
||||
|
||||
export function formatRecordDetailContent(value?: string | null) {
|
||||
const text = value?.trim()
|
||||
if (!text) return '无内容'
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function parsedRecordMessage(value?: string | null) {
|
||||
if (!value) return ''
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(value) as unknown
|
||||
if (typeof payload !== 'object' || payload === null) return ''
|
||||
const data = payload as Record<string, unknown>
|
||||
const candidates = [data.Data, data.Description, data.message, data.error]
|
||||
const hit = candidates.find((candidate) => typeof candidate === 'string' && candidate.trim())
|
||||
return typeof hit === 'string' ? hit.trim() : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function recordResponseSummary(
|
||||
record: Pick<CheckInRecord, 'response_text' | 'error_message'>,
|
||||
maxLength = 96,
|
||||
) {
|
||||
const source = record.response_text || record.error_message || ''
|
||||
const parsed = parsedRecordMessage(source)
|
||||
const raw = parsed || source.trim() || '无响应内容'
|
||||
const normalized = raw.replace(/\s+/g, ' ')
|
||||
if (normalized.length <= maxLength) return normalized
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1))}…`
|
||||
}
|
||||
Reference in New Issue
Block a user