feat(admin): manual request-records cleanup with typed confirmation

This commit is contained in:
Kayphoon
2026-05-13 00:36:43 +08:00
parent 8714d93d4b
commit 15800d7a80
21 changed files with 1064 additions and 28 deletions

View File

@@ -1,8 +1,20 @@
import apiClient from './client'
import axios from 'axios'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
function extractConflictPayload(error: unknown): ManualUsageCleanupConflict | null {
if (!axios.isAxiosError(error) || error.response?.status !== 409) {
return null
}
const data = error.response.data as ManualUsageCleanupConflict | undefined
if (!data || data.detail !== 'usage_cleanup_already_running') {
return null
}
return data
}
// LDAP 配置导出结构
export interface LDAPConfigExport {
server_url: string
@@ -280,6 +292,43 @@ export interface CleanupTaskResponse {
task: CleanupRunRecord
}
export interface ManualUsageCleanupSummary {
body_externalized: number
legacy_body_refs_migrated: number
body_cleaned: number
header_cleaned: number
keys_cleaned: number
records_deleted: number
}
export interface ManualUsageCleanupResponse {
message: string
requested_older_than_days: number | null
summary: ManualUsageCleanupSummary
total_affected: number
}
export interface ManualUsageCleanupPreview {
requested_older_than_days: number | null
effective_cutoffs: {
detail: string
compressed: string
header: string
log: string
}
counts: {
detail: number
compressed: number
header: number
log: number
}
}
export interface ManualUsageCleanupConflict {
detail: 'usage_cleanup_already_running'
message: string
}
// 检查更新响应
export interface CheckUpdateResponse {
current_version: string
@@ -1132,6 +1181,42 @@ export const adminApi = {
return response.data
},
async runManualUsageCleanup(
params: { older_than_days?: number } = {}
): Promise<ManualUsageCleanupResponse | ManualUsageCleanupConflict> {
const body: Record<string, number> = {}
if (typeof params.older_than_days === 'number') {
body.older_than_days = params.older_than_days
}
try {
const response = await apiClient.post<ManualUsageCleanupResponse>(
'/api/admin/system/cleanup/usage/manual',
body
)
return response.data
} catch (error) {
const conflict = extractConflictPayload(error)
if (conflict) {
return conflict
}
throw error
}
},
async previewManualUsageCleanup(
params: { older_than_days?: number } = {}
): Promise<ManualUsageCleanupPreview> {
const query: Record<string, number> = {}
if (typeof params.older_than_days === 'number') {
query.older_than_days = params.older_than_days
}
const response = await apiClient.get<ManualUsageCleanupPreview>(
'/api/admin/system/cleanup/usage/preview',
{ params: query }
)
return response.data
},
async getTimeSeries(params?: {
start_date?: string
end_date?: string

View File

@@ -23,6 +23,15 @@
</p>
</div>
</div>
<Button
variant="destructive"
size="sm"
:disabled="manualCleanupRunning"
@click="openManualCleanupDialog"
>
<Trash2 class="w-3.5 h-3.5 mr-1.5" />
{{ manualCleanupRunning ? '清理中…' : '立即清理' }}
</Button>
<Button
size="sm"
:disabled="loading || !hasChanges"
@@ -276,6 +285,27 @@
</div>
</div>
<ManualCleanupConfirmDialog
:open="manualCleanupDialogOpen"
@update:open="manualCleanupDialogOpen = $event"
@confirm="handleManualCleanupConfirm"
/>
<div
v-if="manualCleanupResult"
class="mt-4 rounded-md border border-border bg-muted/30 px-4 py-3 text-sm"
>
<div class="font-medium">
{{ manualCleanupResult.title }}
</div>
<div
v-if="manualCleanupResult.description"
class="mt-1 text-xs text-muted-foreground"
>
{{ manualCleanupResult.description }}
</div>
</div>
<div class="mt-4 border border-border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-border">
<div>
@@ -371,13 +401,16 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import { adminApi, type CleanupRunRecord } from '@/api/admin'
import { RefreshCw, Trash2 } from 'lucide-vue-next'
import { adminApi, type CleanupRunRecord, type ManualUsageCleanupResponse } from '@/api/admin'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Switch from '@/components/ui/switch.vue'
import { CardSection } from '@/components/layout'
import ManualCleanupConfirmDialog from './ManualCleanupConfirmDialog.vue'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
defineProps<{
enableAutoCleanup: boolean
@@ -416,6 +449,64 @@ const cleanupRuns = ref<CleanupRunRecord[]>([])
const cleanupRunsLoading = ref(false)
let cleanupRunsTimer: ReturnType<typeof window.setInterval> | null = null
const manualCleanupDialogOpen = ref(false)
const manualCleanupRunning = ref(false)
const manualCleanupResult = ref<{ title: string; description?: string } | null>(null)
const toast = useToast()
function openManualCleanupDialog() {
if (manualCleanupRunning.value) return
manualCleanupDialogOpen.value = true
}
async function handleManualCleanupConfirm(olderThanDays: number | undefined) {
manualCleanupRunning.value = true
try {
const response = await adminApi.runManualUsageCleanup(
typeof olderThanDays === 'number' ? { older_than_days: olderThanDays } : {},
)
if ('detail' in response && response.detail === 'usage_cleanup_already_running') {
manualCleanupResult.value = {
title: '已有一次清理正在进行中',
description: response.message,
}
toast.warning(response.message)
} else {
const completed = response as ManualUsageCleanupResponse
manualCleanupResult.value = {
title: completed.message,
description: summarizeManualCleanup(completed),
}
toast.success(completed.message)
}
manualCleanupDialogOpen.value = false
} catch (error) {
const message = parseApiError(error).message
manualCleanupResult.value = {
title: '请求记录清理失败',
description: message,
}
toast.error(message)
} finally {
manualCleanupRunning.value = false
void loadCleanupRuns()
}
}
function summarizeManualCleanup(response: ManualUsageCleanupResponse): string {
const { summary } = response
const parts: string[] = []
if (summary.records_deleted > 0) parts.push(`删除整条记录 ${summary.records_deleted}`)
if (summary.body_externalized > 0) parts.push(`压缩 body ${summary.body_externalized}`)
if (summary.body_cleaned > 0) parts.push(`清除过期 body ${summary.body_cleaned}`)
if (summary.header_cleaned > 0) parts.push(`清除 headers ${summary.header_cleaned}`)
if (summary.legacy_body_refs_migrated > 0) {
parts.push(`迁移遗留引用 ${summary.legacy_body_refs_migrated}`)
}
if (summary.keys_cleaned > 0) parts.push(`回收 Key ${summary.keys_cleaned}`)
return parts.length > 0 ? parts.join(' / ') : '无数据变更'
}
async function loadCleanupRuns() {
cleanupRunsLoading.value = true
try {

View File

@@ -0,0 +1,268 @@
<template>
<Dialog
:open="open"
size="lg"
title="立即清理请求记录"
description="按现有分级保留策略主动清理请求记录,可选指定清理更早时间的数据。操作不可逆。"
:persistent="submitting"
@update:open="handleOpenChange"
>
<div class="px-4 sm:px-6 py-4 space-y-4">
<div>
<Label
for="manual-cleanup-older-than-days"
class="block text-sm font-medium"
>
清理 N 天前的记录可选
</Label>
<Input
id="manual-cleanup-older-than-days"
:model-value="olderThanDays ?? ''"
type="number"
min="1"
placeholder="留空代表按当前保留策略"
class="mt-1"
:disabled="submitting"
@update:model-value="handleDaysChange"
/>
<p class="mt-1 text-xs text-muted-foreground">
留空代表按当前保留策略清理填入数字代表清理 N 天前的记录该值只能比策略更宽松不会删除更新的数据
</p>
</div>
<div class="rounded-md border border-border bg-muted/30 px-4 py-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">
预计影响
</h4>
<button
v-if="!previewLoading"
type="button"
class="text-xs text-muted-foreground hover:text-foreground"
:disabled="submitting"
@click="loadPreview"
>
刷新预估
</button>
<span
v-else
class="text-xs text-muted-foreground"
>
正在计算
</span>
</div>
<div
v-if="previewError"
class="mt-2 text-xs text-destructive"
>
{{ previewError }}
</div>
<div
v-else-if="preview"
class="mt-2 grid grid-cols-2 gap-y-1 gap-x-4 text-xs text-muted-foreground"
>
<div>详细记录待压缩</div>
<div class="text-right text-foreground">
{{ formatCount(preview.counts.detail) }}
</div>
<div>压缩记录待清体</div>
<div class="text-right text-foreground">
{{ formatCount(preview.counts.compressed) }}
</div>
<div>请求头待清空</div>
<div class="text-right text-foreground">
{{ formatCount(preview.counts.header) }}
</div>
<div>整条记录待删除</div>
<div class="text-right text-destructive font-medium">
{{ formatCount(preview.counts.log) }}
</div>
</div>
<div
v-else-if="!previewLoading"
class="mt-2 text-xs text-muted-foreground"
>
尚未计算预估数据
</div>
</div>
<div>
<Label
for="manual-cleanup-confirm-phrase"
class="block text-sm font-medium"
>
输入{{ confirmPhrase }}以确认清理
</Label>
<Input
id="manual-cleanup-confirm-phrase"
:model-value="typedPhrase"
class="mt-1"
autocomplete="off"
:placeholder="confirmPhrase"
:disabled="submitting"
@update:model-value="typedPhrase = String($event)"
@keydown.enter.prevent="maybeSubmitOnEnter"
/>
<p class="mt-1 text-xs text-muted-foreground">
确认操作后会立刻执行清理且不可撤销
</p>
</div>
</div>
<template #footer>
<Button
variant="destructive"
:disabled="!canSubmit"
@click="handleConfirm"
>
{{ submitting ? '清理中…' : '确认清理' }}
</Button>
<Button
variant="outline"
:disabled="submitting"
@click="handleCancel"
>
取消
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import { adminApi, type ManualUsageCleanupPreview } from '@/api/admin'
import { parseApiError } from '@/utils/errorParser'
import {
MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE,
isConfirmPhraseMatched,
normalizeOlderThanDaysInput,
} from './manualCleanupForm'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
confirm: [olderThanDays: number | undefined]
}>()
const confirmPhrase = MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE
const olderThanDays = ref<number | null>(null)
const typedPhrase = ref('')
const preview = ref<ManualUsageCleanupPreview | null>(null)
const previewLoading = ref(false)
const previewError = ref<string | null>(null)
const submitting = ref(false)
let previewDebounceTimer: ReturnType<typeof setTimeout> | null = null
let previewSeq = 0
const normalizedPhrase = computed(() => typedPhrase.value)
const canSubmit = computed(
() =>
!submitting.value &&
!previewLoading.value &&
isConfirmPhraseMatched(normalizedPhrase.value),
)
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
resetForm()
void loadPreview()
} else if (previewDebounceTimer) {
clearTimeout(previewDebounceTimer)
previewDebounceTimer = null
}
},
)
function resetForm() {
olderThanDays.value = null
typedPhrase.value = ''
preview.value = null
previewError.value = null
previewLoading.value = false
submitting.value = false
}
function handleDaysChange(value: string | number) {
olderThanDays.value = normalizeOlderThanDaysInput(value)
schedulePreview()
}
function schedulePreview() {
if (previewDebounceTimer) {
clearTimeout(previewDebounceTimer)
}
previewDebounceTimer = setTimeout(() => {
previewDebounceTimer = null
void loadPreview()
}, 300)
}
async function loadPreview() {
const seq = ++previewSeq
previewLoading.value = true
previewError.value = null
try {
const params: { older_than_days?: number } = {}
if (olderThanDays.value !== null) {
params.older_than_days = olderThanDays.value
}
const result = await adminApi.previewManualUsageCleanup(params)
if (seq === previewSeq) {
preview.value = result
}
} catch (error) {
if (seq === previewSeq) {
preview.value = null
previewError.value = parseApiError(error).message
}
} finally {
if (seq === previewSeq) {
previewLoading.value = false
}
}
}
function handleOpenChange(value: boolean) {
if (!value && submitting.value) {
return
}
emit('update:open', value)
}
function handleCancel() {
if (submitting.value) return
emit('update:open', false)
}
function maybeSubmitOnEnter() {
if (canSubmit.value) {
void handleConfirm()
}
}
async function handleConfirm() {
if (!canSubmit.value) return
submitting.value = true
try {
emit('confirm', olderThanDays.value ?? undefined)
} finally {
submitting.value = false
}
}
function formatCount(value: number): string {
return value.toLocaleString()
}
</script>

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import {
MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE,
isConfirmPhraseMatched,
normalizeConfirmPhraseInput,
normalizeOlderThanDaysInput,
} from '../manualCleanupForm'
describe('manualCleanupForm', () => {
describe('normalizeConfirmPhraseInput', () => {
it('trims leading and trailing whitespace', () => {
expect(normalizeConfirmPhraseInput(' 确认清理 ')).toBe(MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE)
})
it('strips newlines typed or pasted into the input', () => {
expect(normalizeConfirmPhraseInput('确认清理\n')).toBe(MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE)
expect(normalizeConfirmPhraseInput('确认\n清理')).toBe('确认清理')
})
it('leaves non-whitespace content untouched', () => {
expect(normalizeConfirmPhraseInput('取消')).toBe('取消')
})
})
describe('isConfirmPhraseMatched', () => {
it('matches the exact phrase including pasted whitespace', () => {
expect(isConfirmPhraseMatched('确认清理')).toBe(true)
expect(isConfirmPhraseMatched(' 确认清理 ')).toBe(true)
expect(isConfirmPhraseMatched('确认清理\n')).toBe(true)
})
it('rejects partial prefixes and unrelated strings', () => {
expect(isConfirmPhraseMatched('确认')).toBe(false)
expect(isConfirmPhraseMatched('确认清理了')).toBe(false)
expect(isConfirmPhraseMatched('')).toBe(false)
expect(isConfirmPhraseMatched('取消')).toBe(false)
})
it('is case/character sensitive', () => {
expect(isConfirmPhraseMatched('Confirm')).toBe(false)
})
})
describe('normalizeOlderThanDaysInput', () => {
it('returns null for empty or non-positive inputs', () => {
expect(normalizeOlderThanDaysInput('')).toBeNull()
expect(normalizeOlderThanDaysInput(null)).toBeNull()
expect(normalizeOlderThanDaysInput(undefined)).toBeNull()
expect(normalizeOlderThanDaysInput(0)).toBeNull()
expect(normalizeOlderThanDaysInput(-3)).toBeNull()
})
it('returns an integer for positive numeric inputs', () => {
expect(normalizeOlderThanDaysInput(7)).toBe(7)
expect(normalizeOlderThanDaysInput('30')).toBe(30)
expect(normalizeOlderThanDaysInput('7.9')).toBe(7)
})
it('returns null for non-numeric strings', () => {
expect(normalizeOlderThanDaysInput('abc')).toBeNull()
})
})
})

View File

@@ -0,0 +1,16 @@
export const MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE = '确认清理'
export function normalizeConfirmPhraseInput(raw: string): string {
return raw.replace(/\r?\n/g, '').trim()
}
export function isConfirmPhraseMatched(raw: string): boolean {
return normalizeConfirmPhraseInput(raw) === MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE
}
export function normalizeOlderThanDaysInput(raw: string | number | null | undefined): number | null {
if (raw === null || raw === undefined || raw === '') return null
const parsed = typeof raw === 'number' ? raw : Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) return null
return Math.floor(parsed)
}