feat(admin): improve record management

This commit is contained in:
2026-05-05 20:26:26 +08:00
parent 1093923a18
commit 7f9ac0957c
5 changed files with 358 additions and 46 deletions
+5 -1
View File
@@ -171,6 +171,7 @@ async def get_all_check_in_records(
status_filter: Optional[str] = Query( status_filter: Optional[str] = Query(
None, alias="status", description="过滤状态 (success/failure)" None, alias="status", description="过滤状态 (success/failure)"
), ),
trigger_type: Optional[str] = Query(None, description="过滤触发类型 (scheduled/manual/admin)"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user), current_user: User = Depends(get_current_admin_user),
): ):
@@ -181,9 +182,12 @@ async def get_all_check_in_records(
- **limit**: 限制记录数 - **limit**: 限制记录数
- **task_id**: 过滤指定任务的记录 - **task_id**: 过滤指定任务的记录
- **status**: 过滤指定状态的记录 - **status**: 过滤指定状态的记录
- **trigger_type**: 过滤触发类型 (scheduled/manual/admin)
""" """
try: try:
records, total = CheckInService.get_all_records(db, skip, limit, task_id, status_filter) records, total = CheckInService.get_all_records(
db, skip, limit, task_id, status_filter, trigger_type
)
# 为每条记录添加用户和任务信息 # 为每条记录添加用户和任务信息
enriched_records = [ enriched_records = [
CheckInService.enrich_record_with_user_task_info(record, db) for record in records CheckInService.enrich_record_with_user_task_info(record, db) for record in records
+1
View File
@@ -33,6 +33,7 @@ class CheckInRecordResponse(BaseModel):
# 新增字段:用户和任务信息(用于管理员查看) # 新增字段:用户和任务信息(用于管理员查看)
user_id: Optional[int] = Field(None, description="用户 ID") user_id: Optional[int] = Field(None, description="用户 ID")
user_alias: Optional[str] = Field(None, description="用户别名")
user_email: Optional[str] = Field(None, description="用户邮箱") user_email: Optional[str] = Field(None, description="用户邮箱")
task_name: Optional[str] = Field(None, description="任务名称") task_name: Optional[str] = Field(None, description="任务名称")
thread_id: Optional[str] = Field(None, description="接龙 ID") thread_id: Optional[str] = Field(None, description="接龙 ID")
@@ -471,6 +471,7 @@ class CheckInService:
limit: int = 100, limit: int = 100,
task_id: Optional[int] = None, task_id: Optional[int] = None,
status: Optional[str] = None, status: Optional[str] = None,
trigger_type: Optional[str] = None,
) -> tuple[List[CheckInRecord], int]: ) -> tuple[List[CheckInRecord], int]:
""" """
获取所有打卡记录(管理员)- 使用联表查询优化性能 获取所有打卡记录(管理员)- 使用联表查询优化性能
@@ -481,6 +482,7 @@ class CheckInService:
limit: 限制记录数 limit: 限制记录数
task_id: 过滤任务 ID task_id: 过滤任务 ID
status: 过滤状态 status: 过滤状态
trigger_type: 过滤触发类型
Returns: Returns:
(打卡记录列表, 总记录数) (打卡记录列表, 总记录数)
@@ -498,6 +500,9 @@ class CheckInService:
if status: if status:
query = query.filter(CheckInRecord.status == status) query = query.filter(CheckInRecord.status == status)
if trigger_type:
query = query.filter(CheckInRecord.trigger_type == trigger_type)
# 获取总数 # 获取总数
total = query.count() total = query.count()
@@ -557,6 +562,7 @@ class CheckInService:
"trigger_type": record.trigger_type, "trigger_type": record.trigger_type,
"check_in_time": record.check_in_time, "check_in_time": record.check_in_time,
"user_id": user.id if user else None, "user_id": user.id if user else None,
"user_alias": user.alias if user else None,
"user_email": user.email if user else None, "user_email": user.email if user else None,
"task_name": task_name, "task_name": task_name,
"thread_id": thread_id, "thread_id": thread_id,
@@ -1,27 +1,73 @@
<script setup lang="ts"> <script setup lang="ts">
import { Search } from 'lucide-vue-next' import {
import { onMounted, reactive, ref } from 'vue' 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 { checkInApi, type CheckInRecord } from '@/api'
import StateBlock from '@/components/StateBlock.vue' 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 { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format' import { extractErrorMessage, formatFullDateTime, statusLabel, statusTone } from '@/utils/format'
import {
formatRecordDetailContent,
recordResponseSummary,
visibleRecordRange,
} from './admin-records'
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const records = ref<CheckInRecord[]>([]) 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() { async function load() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const page = await checkInApi.allRecords({ const page = await checkInApi.allRecords(normalizedParams())
limit: filters.limit,
task_id: filters.task_id,
status: filters.status,
})
records.value = page.records records.value = page.records
total.value = page.total
filters.skip = page.skip
filters.limit = page.limit
} catch (err) { } catch (err) {
error.value = extractErrorMessage(err) error.value = extractErrorMessage(err)
} finally { } 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) onMounted(load)
</script> </script>
<template> <template>
<section :class="[cardClass, 'overflow-hidden']"> <section :class="[cardClass, 'overflow-hidden']">
<div :class="[sectionHeaderClass, 'lg:grid-cols-[1fr_120px_160px_120px_auto]']"> <div :class="sectionHeaderClass">
<div> <div class="min-w-0">
<h2 class="font-semibold">全量记录</h2> <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> </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>
<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-if="loading" title="正在加载记录" type="loading" />
<StateBlock <StateBlock
v-else-if="error" v-else-if="error"
@@ -61,31 +182,165 @@ onMounted(load)
action-label="重试" action-label="重试"
@action="load" @action="load"
/> />
<StateBlock v-else-if="records.length === 0" title="暂无记录" /> <StateBlock
<div v-else class="divide-y divide-border"> v-else-if="records.length === 0"
<article :title="hasActiveFilters ? '没有匹配记录' : '暂无记录'"
v-for="record in records" :description="hasActiveFilters ? '调整筛选条件或重置后再试' : undefined"
:key="record.id" :action-label="hasActiveFilters ? '重置筛选' : undefined"
class="grid gap-3 p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center" @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"> <span>用户</span>
<div class="truncate font-medium"> <span>任务</span>
{{ record.user_alias || record.user_email || `任务 #${record.task_id}` }} <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>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground"> <div class="min-w-0">
<span>{{ formatFullDateTime(record.check_in_time) }}</span> <div class="truncate text-sm font-medium">
<span>{{ record.response_text || record.error_message || '无响应内容' }}</span> {{ 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>
<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>
<div class="flex flex-wrap items-center gap-2 md:justify-end"> </div>
<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> </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> </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))}`
}