mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 10:27:46 +08:00
Merge remote-tracking branch 'upstream/main' into feat/356-usage-record-columns
This commit is contained in:
@@ -511,7 +511,7 @@
|
||||
<span>用户: {{ selectedTask.username }}</span>
|
||||
</template>
|
||||
<span class="opacity-40">|</span>
|
||||
<span>{{ displayTaskSource(selectedTask) }}</span>
|
||||
<span>{{ displayTaskSource(selectedTask) }}</span>
|
||||
</div>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
|
||||
@@ -41,12 +41,19 @@
|
||||
:class="selectedType === '__new__' ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'"
|
||||
@click="selectNewConfig()"
|
||||
>
|
||||
<div class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
<div
|
||||
class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="selectedType === '__new__' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'"
|
||||
>+</div>
|
||||
>
|
||||
+
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="truncate font-medium text-sm">新配置</div>
|
||||
<div class="text-[10px] text-muted-foreground">未保存</div>
|
||||
<div class="truncate font-medium text-sm">
|
||||
新配置
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
未保存
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -70,10 +77,12 @@
|
||||
src="https://cdn.linux.do/uploads/default/optimized/3X/9/d/9dd49731091ce8656243f3c2b6e5d5e5a7e3e3e3_2_32x32.png"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
@error="($event.target as HTMLImageElement).remove()"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="truncate font-medium text-sm">{{ item.display_name }}</div>
|
||||
<div class="truncate font-medium text-sm">
|
||||
{{ item.display_name }}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
{{ item.configured ? (item.is_enabled ? '已启用' : '已禁用') : '未配置' }}
|
||||
</div>
|
||||
@@ -96,262 +105,261 @@
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 配置表单 -->
|
||||
<CardSection
|
||||
v-if="selectedType"
|
||||
:title="selectedType === '__new__' ? '新建配置' : (selectedTypeMeta?.display_name || selectedType)"
|
||||
:description="selectedType === '__new__' ? '填写后点击保存' : (configs[selectedType]?.is_enabled ? '已启用' : '已禁用')"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="saving || testing"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ testing ? '测试中...' : '测试' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<CardSection
|
||||
v-if="selectedType"
|
||||
:title="selectedType === '__new__' ? '新建配置' : (selectedTypeMeta?.display_name || selectedType)"
|
||||
:description="selectedType === '__new__' ? '填写后点击保存' : (configs[selectedType]?.is_enabled ? '已启用' : '已禁用')"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="saving || testing"
|
||||
@click="handleTest"
|
||||
<div class="space-y-6">
|
||||
<!-- 新建时的 Display Name -->
|
||||
<div
|
||||
v-if="selectedType === '__new__'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
{{ testing ? '测试中...' : '测试' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- 新建时的 Display Name -->
|
||||
<div
|
||||
v-if="selectedType === '__new__'"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">显示名称</Label>
|
||||
<Input
|
||||
v-model="form.new_display_name"
|
||||
class="mt-1"
|
||||
placeholder="例如:My OIDC Provider"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">配置标识</Label>
|
||||
<Input
|
||||
v-model="form.new_provider_type"
|
||||
class="mt-1"
|
||||
placeholder="custom_oidc_work"
|
||||
autocomplete="off"
|
||||
@blur="normalizeNewProviderType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 凭证配置 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client ID</Label>
|
||||
<Input
|
||||
v-model="form.client_id"
|
||||
class="mt-1"
|
||||
placeholder="client_id"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client Secret</Label>
|
||||
<Input
|
||||
v-model="form.client_secret"
|
||||
masked
|
||||
class="mt-1"
|
||||
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回调地址 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Redirect URI(后端回调)</Label>
|
||||
<Input
|
||||
v-model="form.redirect_uri"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:8084/api/oauth/xxx/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">前端回调页</Label>
|
||||
<Input
|
||||
v-model="form.frontend_callback_url"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:5173/auth/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- custom_oidc 必填端点 -->
|
||||
<div
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/authorize"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/token"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/api/user"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高级选项(折叠) -->
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
高级选项
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Scopes</Label>
|
||||
<Label class="block text-sm font-medium">显示名称</Label>
|
||||
<Input
|
||||
v-model="form.scopes_input"
|
||||
v-model="form.new_display_name"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
|
||||
placeholder="例如:My OIDC Provider"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
空格/逗号分隔;留空使用默认值
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- linuxdo 的可选端点覆盖 -->
|
||||
<div
|
||||
v-if="!isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">配置标识</Label>
|
||||
<Input
|
||||
v-model="form.new_provider_type"
|
||||
class="mt-1"
|
||||
placeholder="custom_oidc_work"
|
||||
autocomplete="off"
|
||||
@blur="normalizeNewProviderType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- 凭证配置 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client ID</Label>
|
||||
<Input
|
||||
v-model="form.client_id"
|
||||
class="mt-1"
|
||||
placeholder="client_id"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Client Secret</Label>
|
||||
<Input
|
||||
v-model="form.client_secret"
|
||||
masked
|
||||
class="mt-1"
|
||||
:placeholder="hasSecret ? '已设置(留空保持不变)' : '请输入 secret'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回调地址 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Redirect URI(后端回调)</Label>
|
||||
<Input
|
||||
v-model="form.redirect_uri"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:8084/api/oauth/xxx/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">前端回调页</Label>
|
||||
<Input
|
||||
v-model="form.frontend_callback_url"
|
||||
class="mt-1"
|
||||
placeholder="http://localhost:5173/auth/callback"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- custom_oidc 必填端点 -->
|
||||
<div
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/authorize"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/oauth/token"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
placeholder="https://example.com/api/user"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高级选项(折叠) -->
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
高级选项
|
||||
</summary>
|
||||
<div class="mt-4 space-y-4 pl-4 border-l-2 border-border">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Attribute Mapping</Label>
|
||||
<Textarea
|
||||
v-model="form.attribute_mapping_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
placeholder="{"id": "user_id", "username": "login"}"
|
||||
<Label class="block text-sm font-medium">Scopes</Label>
|
||||
<Input
|
||||
v-model="form.scopes_input"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_scopes?.join(' ') || '留空使用默认值'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
|
||||
</Label>
|
||||
<Textarea
|
||||
v-model="form.extra_config_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
:placeholder="extraConfigPlaceholder"
|
||||
/>
|
||||
<p
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
空格/逗号分隔;留空使用默认值
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div
|
||||
v-if="lastTestResult"
|
||||
class="mt-6 rounded-lg border border-border p-4 text-sm"
|
||||
>
|
||||
<div class="font-medium mb-2">
|
||||
测试结果
|
||||
<!-- linuxdo 的可选端点覆盖 -->
|
||||
<div
|
||||
v-if="!isSelectedCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Authorization URL</Label>
|
||||
<Input
|
||||
v-model="form.authorization_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_authorization_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Token URL</Label>
|
||||
<Input
|
||||
v-model="form.token_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_token_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Userinfo URL</Label>
|
||||
<Input
|
||||
v-model="form.userinfo_url_override"
|
||||
class="mt-1"
|
||||
:placeholder="selectedTypeMeta?.default_userinfo_url || '默认'"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">Attribute Mapping</Label>
|
||||
<Textarea
|
||||
v-model="form.attribute_mapping_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
placeholder="{"id": "user_id", "username": "login"}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
{{ isSelectedCustomProvider ? 'Allowed Domains / Extra Config' : 'Extra Config' }}
|
||||
</Label>
|
||||
<Textarea
|
||||
v-model="form.extra_config_json"
|
||||
class="mt-1 font-mono text-xs"
|
||||
rows="3"
|
||||
:placeholder="extraConfigPlaceholder"
|
||||
/>
|
||||
<p
|
||||
v-if="isSelectedCustomProvider"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.authorization_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Authorization URL</span>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div
|
||||
v-if="lastTestResult"
|
||||
class="mt-6 rounded-lg border border-border p-4 text-sm"
|
||||
>
|
||||
<div class="font-medium mb-2">
|
||||
测试结果
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap gap-4 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.authorization_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Authorization URL</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.token_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Token URL</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.secret_status === 'likely_valid' ? 'bg-green-500' : lastTestResult.secret_status === 'invalid' ? 'bg-red-500' : 'bg-yellow-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Secret: {{ lastTestResult.secret_status }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.token_url_reachable ? 'bg-green-500' : 'bg-red-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Token URL</span>
|
||||
v-if="lastTestResult.details"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
{{ lastTestResult.details }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="lastTestResult.secret_status === 'likely_valid' ? 'bg-green-500' : lastTestResult.secret_status === 'invalid' ? 'bg-red-500' : 'bg-yellow-500'"
|
||||
/>
|
||||
<span class="text-muted-foreground">Secret: {{ lastTestResult.secret_status }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="lastTestResult.details"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
{{ lastTestResult.details }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</CardSection>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
@@ -822,7 +822,6 @@
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,9 @@
|
||||
<div>API 端点</div>
|
||||
<div>推理程度</div>
|
||||
<div>映射参数</div>
|
||||
<div class="md:text-right">状态</div>
|
||||
<div class="md:text-right">
|
||||
状态
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div
|
||||
|
||||
@@ -288,7 +288,8 @@
|
||||
<ManualCleanupConfirmDialog
|
||||
:open="manualCleanupDialogOpen"
|
||||
@update:open="manualCleanupDialogOpen = $event"
|
||||
@confirm="handleManualCleanupConfirm"
|
||||
@running-change="manualCleanupRunning = $event"
|
||||
@completed="handleManualCleanupCompleted"
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -402,7 +403,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { RefreshCw, Trash2 } from 'lucide-vue-next'
|
||||
import { adminApi, type CleanupRunRecord, type ManualUsageCleanupResponse } from '@/api/admin'
|
||||
import { adminApi, type CleanupRunRecord } from '@/api/admin'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
@@ -410,7 +411,6 @@ 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
|
||||
@@ -459,52 +459,18 @@ function openManualCleanupDialog() {
|
||||
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 handleManualCleanupCompleted(task: CleanupRunRecord) {
|
||||
manualCleanupRunning.value = false
|
||||
manualCleanupResult.value = {
|
||||
title: task.message,
|
||||
description: cleanupSummaryText(task.summary),
|
||||
}
|
||||
}
|
||||
|
||||
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 (task.status === 'failed') {
|
||||
toast.error(task.error || task.message)
|
||||
} else {
|
||||
toast.success(task.message)
|
||||
}
|
||||
if (summary.keys_cleaned > 0) parts.push(`回收 Key ${summary.keys_cleaned}`)
|
||||
return parts.length > 0 ? parts.join(' / ') : '无数据变更'
|
||||
void loadCleanupRuns()
|
||||
}
|
||||
|
||||
async function loadCleanupRuns() {
|
||||
@@ -568,7 +534,7 @@ function cleanupSummaryText(summary: Record<string, unknown>): string {
|
||||
|
||||
function summaryLabel(key: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
body_externalized: '压缩',
|
||||
body_externalized: '详细体',
|
||||
legacy_body_refs_migrated: '迁移',
|
||||
body_cleaned: '清体',
|
||||
header_cleaned: '清头',
|
||||
|
||||
@@ -3,30 +3,86 @@
|
||||
:open="open"
|
||||
size="lg"
|
||||
title="立即清理请求记录"
|
||||
description="按现有分级保留策略主动清理请求记录,可选指定清理更早时间的数据。操作不可逆。"
|
||||
:persistent="submitting"
|
||||
description="默认按当前分级保留策略执行,也可以选择指定范围。操作不可逆。"
|
||||
:persistent="isLocked"
|
||||
@update:open="handleOpenChange"
|
||||
>
|
||||
<div class="px-4 sm:px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
清理方式
|
||||
</Label>
|
||||
<div class="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="item in modeOptions"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-2 text-left text-sm transition-colors"
|
||||
:class="mode === item.value ? 'border-primary bg-primary/10 text-primary' : 'border-border bg-card hover:bg-muted/60'"
|
||||
:disabled="isLocked"
|
||||
@click="setMode(item.value)"
|
||||
>
|
||||
<span class="font-medium">{{ item.label }}</span>
|
||||
<span class="mt-1 block text-xs text-muted-foreground">{{ item.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'older_than_days'">
|
||||
<Label
|
||||
for="manual-cleanup-older-than-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
清理 N 天前的记录(可选)
|
||||
清理 N 天前的记录
|
||||
</Label>
|
||||
<Input
|
||||
id="manual-cleanup-older-than-days"
|
||||
:model-value="olderThanDays ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空代表按当前保留策略"
|
||||
placeholder="例如 30"
|
||||
class="mt-1"
|
||||
:disabled="submitting"
|
||||
:disabled="isLocked"
|
||||
@update:model-value="handleDaysChange"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
留空代表按当前保留策略清理。填入数字代表清理 N 天前的记录;该值只能比策略更宽松,不会删除更新的数据。
|
||||
该值会与当前策略取更保守的时间点,不会清理比策略更新的数据。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="block text-sm font-medium">
|
||||
清理范围
|
||||
</Label>
|
||||
<div class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label
|
||||
v-for="target in targetOptions"
|
||||
:key="target.value"
|
||||
class="flex min-h-16 items-start gap-3 rounded-md border border-border bg-card px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
class="mt-0.5"
|
||||
:checked="selectedTargets.includes(target.value)"
|
||||
:disabled="isLocked"
|
||||
@update:checked="toggleTarget(target.value, $event)"
|
||||
/>
|
||||
<span>
|
||||
<span class="block text-sm font-medium">{{ target.label }}</span>
|
||||
<span class="block text-xs text-muted-foreground">{{ target.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
v-if="mode === 'before_now'"
|
||||
class="mt-2 text-xs text-amber-600"
|
||||
>
|
||||
当前时刻之前模式只允许清理详细请求体和压缩请求体,不会清请求头或整条记录。
|
||||
</p>
|
||||
<p
|
||||
v-if="targetError"
|
||||
class="mt-2 text-xs text-destructive"
|
||||
>
|
||||
{{ targetError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +95,7 @@
|
||||
v-if="!previewLoading"
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground"
|
||||
:disabled="submitting"
|
||||
:disabled="isLocked"
|
||||
@click="loadPreview"
|
||||
>
|
||||
刷新预估
|
||||
@@ -86,6 +142,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeTask || taskError"
|
||||
class="rounded-md border border-border bg-card px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">
|
||||
{{ activeTask?.message || '请求记录清理失败' }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ activeTask ? cleanupStatusLabel(activeTask.status) : taskError }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="activeTask"
|
||||
:class="cleanupStatusClass(activeTask.status)"
|
||||
class="shrink-0 text-xs"
|
||||
>
|
||||
{{ cleanupStatusLabel(activeTask.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeTask"
|
||||
class="mt-3 h-2 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all"
|
||||
:class="{ 'animate-pulse': activeTask.status === 'processing' }"
|
||||
:style="{ width: `${taskProgressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeTask"
|
||||
class="mt-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ cleanupSummaryText(activeTask.summary) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="manual-cleanup-confirm-phrase"
|
||||
@@ -99,47 +194,59 @@
|
||||
class="mt-1"
|
||||
autocomplete="off"
|
||||
:placeholder="confirmPhrase"
|
||||
:disabled="submitting"
|
||||
:disabled="isLocked || isFinished"
|
||||
@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
|
||||
v-if="!isFinished"
|
||||
variant="destructive"
|
||||
:disabled="!canSubmit"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ submitting ? '清理中…' : '确认清理' }}
|
||||
{{ isLocked ? '清理中…' : '确认清理' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="submitting"
|
||||
:disabled="isLocked"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
{{ isFinished ? '关闭' : '取消' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import { adminApi, type ManualUsageCleanupPreview } from '@/api/admin'
|
||||
import {
|
||||
adminApi,
|
||||
type CleanupRunRecord,
|
||||
type ManualUsageCleanupPreview,
|
||||
type ManualUsageCleanupRequest,
|
||||
} from '@/api/admin'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE,
|
||||
allowedTargetsForMode,
|
||||
defaultManualCleanupTargets,
|
||||
isConfirmPhraseMatched,
|
||||
normalizeManualCleanupTargets,
|
||||
normalizeOlderThanDaysInput,
|
||||
type ManualCleanupMode,
|
||||
type ManualCleanupTarget,
|
||||
} from './manualCleanupForm'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -148,50 +255,124 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
confirm: [olderThanDays: number | undefined]
|
||||
'running-change': [value: boolean]
|
||||
completed: [task: CleanupRunRecord]
|
||||
}>()
|
||||
|
||||
const confirmPhrase = MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE
|
||||
|
||||
const mode = ref<ManualCleanupMode>('policy')
|
||||
const olderThanDays = ref<number | null>(null)
|
||||
const selectedTargets = ref<ManualCleanupTarget[]>(defaultManualCleanupTargets('policy'))
|
||||
const targetsTouched = ref(false)
|
||||
const typedPhrase = ref('')
|
||||
const preview = ref<ManualUsageCleanupPreview | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
const activeTask = ref<CleanupRunRecord | null>(null)
|
||||
const taskError = ref<string | null>(null)
|
||||
|
||||
let previewDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let previewSeq = 0
|
||||
let taskPollTimer: ReturnType<typeof window.setInterval> | null = null
|
||||
|
||||
const normalizedPhrase = computed(() => typedPhrase.value)
|
||||
const modeOptions: Array<{ value: ManualCleanupMode; label: string; description: string }> = [
|
||||
{ value: 'policy', label: '按当前策略', description: '沿用页面上配置的保留天数' },
|
||||
{ value: 'older_than_days', label: '指定天数前', description: '在策略内取更保守时间点' },
|
||||
{ value: 'before_now', label: '当前时刻之前', description: '只清已选请求体内容' },
|
||||
]
|
||||
|
||||
const targetLabels: Record<ManualCleanupTarget, { label: string; description: string }> = {
|
||||
detail_body: { label: '详细请求体', description: '把详细 body 移入压缩/外置存储' },
|
||||
compressed_body: { label: '压缩请求体', description: '删除已压缩或外置的 body 内容' },
|
||||
headers: { label: '请求头', description: '清空请求/响应 headers 字段' },
|
||||
records: { label: '整条记录', description: '删除超过记录保留期的 usage 行' },
|
||||
}
|
||||
|
||||
const targetOptions = computed(() =>
|
||||
allowedTargetsForMode(mode.value).map(value => ({
|
||||
value,
|
||||
...targetLabels[value],
|
||||
}))
|
||||
)
|
||||
|
||||
const normalizedTargets = computed(() =>
|
||||
normalizeManualCleanupTargets(mode.value, selectedTargets.value)
|
||||
)
|
||||
|
||||
const targetError = computed(() => {
|
||||
if (normalizedTargets.value.length === 0) return '至少选择一个清理范围'
|
||||
return null
|
||||
})
|
||||
|
||||
const currentTaskRunning = computed(() => activeTask.value?.status === 'processing')
|
||||
const isLocked = computed(() => submitting.value || currentTaskRunning.value)
|
||||
const isFinished = computed(() =>
|
||||
activeTask.value?.status === 'completed' || activeTask.value?.status === 'failed'
|
||||
)
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
!submitting.value &&
|
||||
!isLocked.value &&
|
||||
!isFinished.value &&
|
||||
!previewLoading.value &&
|
||||
isConfirmPhraseMatched(normalizedPhrase.value),
|
||||
!targetError.value &&
|
||||
modeIsValid.value &&
|
||||
isConfirmPhraseMatched(typedPhrase.value),
|
||||
)
|
||||
|
||||
const modeIsValid = computed(() => mode.value !== 'older_than_days' || olderThanDays.value !== null)
|
||||
|
||||
const taskProgressPercent = computed(() => {
|
||||
const task = activeTask.value
|
||||
if (!task) return 0
|
||||
if (task.status === 'completed') return 100
|
||||
if (task.status === 'failed') return 100
|
||||
const raw = task.summary?.progress_percent
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||
return Math.max(1, Math.min(99, Math.round(raw)))
|
||||
}
|
||||
return 12
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
resetForm()
|
||||
void loadPreview()
|
||||
} else if (previewDebounceTimer) {
|
||||
clearTimeout(previewDebounceTimer)
|
||||
previewDebounceTimer = null
|
||||
} else {
|
||||
clearPreviewTimer()
|
||||
stopTaskPolling()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
mode.value = 'policy'
|
||||
olderThanDays.value = null
|
||||
selectedTargets.value = defaultManualCleanupTargets('policy')
|
||||
targetsTouched.value = false
|
||||
typedPhrase.value = ''
|
||||
preview.value = null
|
||||
previewError.value = null
|
||||
previewLoading.value = false
|
||||
submitting.value = false
|
||||
activeTask.value = null
|
||||
taskError.value = null
|
||||
emit('running-change', false)
|
||||
}
|
||||
|
||||
function setMode(nextMode: ManualCleanupMode) {
|
||||
if (isLocked.value || mode.value === nextMode) return
|
||||
mode.value = nextMode
|
||||
olderThanDays.value = null
|
||||
selectedTargets.value = defaultManualCleanupTargets(nextMode)
|
||||
targetsTouched.value = false
|
||||
activeTask.value = null
|
||||
taskError.value = null
|
||||
schedulePreview()
|
||||
}
|
||||
|
||||
function handleDaysChange(value: string | number) {
|
||||
@@ -199,33 +380,59 @@ function handleDaysChange(value: string | number) {
|
||||
schedulePreview()
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
if (previewDebounceTimer) {
|
||||
clearTimeout(previewDebounceTimer)
|
||||
function toggleTarget(target: ManualCleanupTarget, checked: boolean) {
|
||||
targetsTouched.value = true
|
||||
activeTask.value = null
|
||||
taskError.value = null
|
||||
const current = new Set(selectedTargets.value)
|
||||
if (checked) {
|
||||
current.add(target)
|
||||
} else {
|
||||
current.delete(target)
|
||||
}
|
||||
selectedTargets.value = normalizeManualCleanupTargets(mode.value, Array.from(current))
|
||||
schedulePreview()
|
||||
}
|
||||
|
||||
function buildRequest(): ManualUsageCleanupRequest {
|
||||
const request: ManualUsageCleanupRequest = { mode: mode.value }
|
||||
if (mode.value === 'older_than_days' && olderThanDays.value !== null) {
|
||||
request.older_than_days = olderThanDays.value
|
||||
}
|
||||
if (targetsTouched.value) {
|
||||
request.targets = normalizedTargets.value
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
clearPreviewTimer()
|
||||
previewDebounceTimer = setTimeout(() => {
|
||||
previewDebounceTimer = null
|
||||
void loadPreview()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function clearPreviewTimer() {
|
||||
if (previewDebounceTimer) {
|
||||
clearTimeout(previewDebounceTimer)
|
||||
previewDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
const result = await adminApi.previewManualUsageCleanup(buildRequest())
|
||||
if (seq === previewSeq) {
|
||||
preview.value = result
|
||||
}
|
||||
} catch (error) {
|
||||
if (seq === previewSeq) {
|
||||
preview.value = null
|
||||
previewError.value = parseApiError(error).message
|
||||
previewError.value = parseApiError(error)
|
||||
}
|
||||
} finally {
|
||||
if (seq === previewSeq) {
|
||||
@@ -235,14 +442,14 @@ async function loadPreview() {
|
||||
}
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
if (!value && submitting.value) {
|
||||
if (!value && isLocked.value) {
|
||||
return
|
||||
}
|
||||
emit('update:open', value)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (submitting.value) return
|
||||
if (isLocked.value) return
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
@@ -255,14 +462,101 @@ function maybeSubmitOnEnter() {
|
||||
async function handleConfirm() {
|
||||
if (!canSubmit.value) return
|
||||
submitting.value = true
|
||||
taskError.value = null
|
||||
try {
|
||||
emit('confirm', olderThanDays.value ?? undefined)
|
||||
const response = await adminApi.runManualUsageCleanup(buildRequest())
|
||||
if ('detail' in response && response.detail === 'usage_cleanup_already_running') {
|
||||
taskError.value = response.message
|
||||
emit('running-change', false)
|
||||
return
|
||||
}
|
||||
activeTask.value = response.task
|
||||
emit('running-change', response.task.status === 'processing')
|
||||
if (response.task.status === 'processing') {
|
||||
startTaskPolling(response.task.id)
|
||||
} else {
|
||||
emit('completed', response.task)
|
||||
}
|
||||
} catch (error) {
|
||||
taskError.value = parseApiError(error)
|
||||
emit('running-change', false)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startTaskPolling(taskId: string) {
|
||||
stopTaskPolling()
|
||||
void pollTask(taskId)
|
||||
taskPollTimer = window.setInterval(() => {
|
||||
void pollTask(taskId)
|
||||
}, 1_500)
|
||||
}
|
||||
|
||||
function stopTaskPolling() {
|
||||
if (taskPollTimer) {
|
||||
window.clearInterval(taskPollTimer)
|
||||
taskPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTask(taskId: string) {
|
||||
try {
|
||||
const response = await adminApi.getCleanupRuns()
|
||||
const task = response.items.find(item => item.id === taskId)
|
||||
if (!task) return
|
||||
activeTask.value = task
|
||||
const running = task.status === 'processing'
|
||||
emit('running-change', running)
|
||||
if (!running) {
|
||||
stopTaskPolling()
|
||||
emit('completed', task)
|
||||
void loadPreview()
|
||||
}
|
||||
} catch (error) {
|
||||
taskError.value = parseApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupStatusLabel(status: string): string {
|
||||
if (status === 'processing') return '执行中'
|
||||
if (status === 'failed') return '失败'
|
||||
return '完成'
|
||||
}
|
||||
|
||||
function cleanupStatusClass(status: string): string {
|
||||
if (status === 'processing') return 'text-amber-500'
|
||||
if (status === 'failed') return 'text-destructive'
|
||||
return 'text-emerald-500'
|
||||
}
|
||||
|
||||
function cleanupSummaryText(summary: Record<string, unknown>): string {
|
||||
const total = typeof summary.total === 'number' ? summary.total : null
|
||||
if (total !== null && total > 0) return `影响 ${total} 项`
|
||||
const entries = Object.entries(summary)
|
||||
.filter(([key, value]) => key !== 'progress_percent' && typeof value === 'number' && value > 0)
|
||||
.map(([key, value]) => `${summaryLabel(key)} ${value}`)
|
||||
return entries.length > 0 ? entries.join(' / ') : '等待后台返回结果'
|
||||
}
|
||||
|
||||
function summaryLabel(key: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
body_externalized: '详细体',
|
||||
legacy_body_refs_migrated: '迁移',
|
||||
body_cleaned: '清体',
|
||||
header_cleaned: '清头',
|
||||
keys_cleaned: 'Key',
|
||||
records_deleted: '删记录',
|
||||
}
|
||||
return labels[key] || key
|
||||
}
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPreviewTimer()
|
||||
stopTaskPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
allowedTargetsForMode,
|
||||
defaultManualCleanupTargets,
|
||||
MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE,
|
||||
isConfirmPhraseMatched,
|
||||
normalizeManualCleanupTargets,
|
||||
normalizeConfirmPhraseInput,
|
||||
normalizeOlderThanDaysInput,
|
||||
} from '../manualCleanupForm'
|
||||
@@ -61,4 +64,34 @@ describe('manualCleanupForm', () => {
|
||||
expect(normalizeOlderThanDaysInput('abc')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup targets', () => {
|
||||
it('keeps all ranges available for policy cleanup', () => {
|
||||
expect(allowedTargetsForMode('policy')).toEqual([
|
||||
'detail_body',
|
||||
'compressed_body',
|
||||
'headers',
|
||||
'records',
|
||||
])
|
||||
expect(defaultManualCleanupTargets('older_than_days')).toEqual([
|
||||
'detail_body',
|
||||
'compressed_body',
|
||||
'headers',
|
||||
'records',
|
||||
])
|
||||
})
|
||||
|
||||
it('limits before-now cleanup to body targets', () => {
|
||||
expect(allowedTargetsForMode('before_now')).toEqual([
|
||||
'detail_body',
|
||||
'compressed_body',
|
||||
])
|
||||
expect(normalizeManualCleanupTargets('before_now', [
|
||||
'detail_body',
|
||||
'headers',
|
||||
'compressed_body',
|
||||
'records',
|
||||
])).toEqual(['detail_body', 'compressed_body'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { errorMock, successMock } = vi.hoisted(() => ({
|
||||
errorMock: vi.fn(),
|
||||
successMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
error: errorMock,
|
||||
success: successMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminApi: {},
|
||||
}))
|
||||
|
||||
import { useConfigExportImport } from '../composables/useConfigExportImport'
|
||||
|
||||
function buildFileInputEvent(file: File): Event {
|
||||
return {
|
||||
target: {
|
||||
files: [file],
|
||||
value: 'selected.json',
|
||||
},
|
||||
} as unknown as Event
|
||||
}
|
||||
|
||||
function makeSizedFile(content: string, size: number): File {
|
||||
const file = new File([content], 'config.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'size', { value: size })
|
||||
return file
|
||||
}
|
||||
|
||||
describe('useConfigExportImport file size limits', () => {
|
||||
beforeEach(() => {
|
||||
errorMock.mockReset()
|
||||
successMock.mockReset()
|
||||
})
|
||||
|
||||
it('accepts config import files larger than the old 10MB limit', async () => {
|
||||
const state = useConfigExportImport(ref({ site_name: 'Aether' }))
|
||||
const file = makeSizedFile(
|
||||
JSON.stringify({
|
||||
version: '1',
|
||||
exported_at: '2026-01-01T00:00:00.000Z',
|
||||
global_models: [],
|
||||
providers: [],
|
||||
}),
|
||||
11 * 1024 * 1024
|
||||
)
|
||||
|
||||
state.handleConfigFileSelect(buildFileInputEvent(file))
|
||||
await vi.waitFor(() => expect(state.importDialogOpen.value).toBe(true))
|
||||
|
||||
expect(errorMock).not.toHaveBeenCalledWith('文件大小不能超过 10MB')
|
||||
expect(state.importPreview.value).toEqual({
|
||||
version: '1',
|
||||
exported_at: '2026-01-01T00:00:00.000Z',
|
||||
global_models: [],
|
||||
providers: [],
|
||||
})
|
||||
expect(state.importDialogOpen.value).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the updated config import limit when the file is too large', () => {
|
||||
const state = useConfigExportImport(ref({ site_name: 'Aether' }))
|
||||
const file = makeSizedFile('{}', 501 * 1024 * 1024)
|
||||
|
||||
state.handleConfigFileSelect(buildFileInputEvent(file))
|
||||
|
||||
expect(errorMock).toHaveBeenCalledWith('文件大小不能超过 500MB')
|
||||
})
|
||||
})
|
||||
@@ -13,9 +13,12 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
import type { SystemConfig } from './useSystemConfig'
|
||||
|
||||
// 文件大小限制:聚合数据包含配置和用户数据,允许更大的备份文件。
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const MAX_AGGREGATE_FILE_SIZE = 20 * 1024 * 1024
|
||||
// 文件大小限制:导出文件可能包含大量 Provider Key、模型和用户数据。
|
||||
const BYTES_PER_MB = 1024 * 1024
|
||||
const MAX_FILE_SIZE_MB = 500
|
||||
const MAX_AGGREGATE_FILE_SIZE_MB = 500
|
||||
const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * BYTES_PER_MB
|
||||
const MAX_AGGREGATE_FILE_SIZE = MAX_AGGREGATE_FILE_SIZE_MB * BYTES_PER_MB
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
@@ -49,6 +52,10 @@ function looksLikeAggregateExport(value: JsonObject): boolean {
|
||||
&& asJsonObject(value.user_data) != null
|
||||
}
|
||||
|
||||
function fileSizeLimitMessage(limitMb: number): string {
|
||||
return `文件大小不能超过 ${limitMb}MB`
|
||||
}
|
||||
|
||||
function downloadJson(data: unknown, filename: string) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -126,7 +133,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error('文件大小不能超过 10MB')
|
||||
error(fileSizeLimitMessage(MAX_FILE_SIZE_MB))
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
@@ -223,7 +230,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error('文件大小不能超过 10MB')
|
||||
error(fileSizeLimitMessage(MAX_FILE_SIZE_MB))
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
@@ -325,7 +332,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_AGGREGATE_FILE_SIZE) {
|
||||
error('文件大小不能超过 20MB')
|
||||
error(fileSizeLimitMessage(MAX_AGGREGATE_FILE_SIZE_MB))
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
export const MANUAL_USAGE_CLEANUP_CONFIRM_PHRASE = '确认清理'
|
||||
|
||||
export type ManualCleanupMode = 'policy' | 'older_than_days' | 'before_now'
|
||||
export type ManualCleanupTarget = 'detail_body' | 'compressed_body' | 'headers' | 'records'
|
||||
|
||||
export const MANUAL_CLEANUP_TARGETS: ManualCleanupTarget[] = [
|
||||
'detail_body',
|
||||
'compressed_body',
|
||||
'headers',
|
||||
'records',
|
||||
]
|
||||
|
||||
export const BEFORE_NOW_ALLOWED_TARGETS: ManualCleanupTarget[] = [
|
||||
'detail_body',
|
||||
'compressed_body',
|
||||
]
|
||||
|
||||
export function normalizeConfirmPhraseInput(raw: string): string {
|
||||
return raw.replace(/\r?\n/g, '').trim()
|
||||
}
|
||||
@@ -14,3 +29,21 @@ export function normalizeOlderThanDaysInput(raw: string | number | null | undefi
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
export function allowedTargetsForMode(mode: ManualCleanupMode): ManualCleanupTarget[] {
|
||||
return mode === 'before_now' ? BEFORE_NOW_ALLOWED_TARGETS : MANUAL_CLEANUP_TARGETS
|
||||
}
|
||||
|
||||
export function normalizeManualCleanupTargets(
|
||||
mode: ManualCleanupMode,
|
||||
targets: ManualCleanupTarget[],
|
||||
): ManualCleanupTarget[] {
|
||||
const allowed = new Set(allowedTargetsForMode(mode))
|
||||
return targets.filter((target, index) =>
|
||||
allowed.has(target) && targets.indexOf(target) === index
|
||||
)
|
||||
}
|
||||
|
||||
export function defaultManualCleanupTargets(mode: ManualCleanupMode): ManualCleanupTarget[] {
|
||||
return allowedTargetsForMode(mode)
|
||||
}
|
||||
|
||||
@@ -50,15 +50,15 @@
|
||||
>
|
||||
<UsageModelTable
|
||||
:data="enhancedModelStats"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
<UsageProviderTable
|
||||
:data="providerStats"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
<UsageApiFormatTable
|
||||
:data="apiFormatStats"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
</div>
|
||||
<!-- 用户:模型 + API格式(2列) -->
|
||||
@@ -68,7 +68,7 @@
|
||||
>
|
||||
<UsageModelTable
|
||||
:data="enhancedModelStats"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
:is-admin="authStore.canAccessAdmin"
|
||||
/>
|
||||
<UsageApiFormatTable
|
||||
:data="apiFormatStats"
|
||||
@@ -81,7 +81,7 @@
|
||||
<UsageRecordsTable
|
||||
:records="displayRecords"
|
||||
:is-admin="isAdminPage"
|
||||
:show-actual-cost="authStore.canAccessAdmin"
|
||||
:show-actual-cost="authStore.canAccessAdmin"
|
||||
:loading="isLoadingRecords"
|
||||
:time-range="timeRange"
|
||||
:filter-search="filterSearch"
|
||||
|
||||
Reference in New Issue
Block a user