1704 lines
85 KiB
Vue
1704 lines
85 KiB
Vue
<template>
|
|
<AppLayout>
|
|
<div class="space-y-6">
|
|
<div v-if="loading" class="flex items-center justify-center py-16">
|
|
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.title') }}</h1>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.description') }}</p>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<button type="button" class="btn btn-secondary inline-flex items-center gap-2" :disabled="statusLoading" @click="loadStatus(false)">
|
|
<Icon name="refresh" size="sm" :class="statusLoading ? 'animate-spin' : ''" />
|
|
{{ t('admin.riskControl.refreshStatus') }}
|
|
</button>
|
|
<button type="button" class="btn btn-primary inline-flex items-center gap-2" @click="openSettings">
|
|
<Icon name="cog" size="sm" />
|
|
{{ t('admin.riskControl.openSettings') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
<div
|
|
v-for="item in overviewItems"
|
|
:key="item.key"
|
|
class="rounded-lg border border-gray-100 bg-white px-4 py-3 shadow-sm dark:border-dark-700 dark:bg-dark-800"
|
|
>
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg" :class="item.iconClass">
|
|
<Icon :name="item.icon" size="sm" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex min-w-0 items-center justify-between gap-2">
|
|
<p class="truncate text-xs font-medium text-gray-500 dark:text-gray-400">{{ item.label }}</p>
|
|
<span
|
|
v-if="item.badge"
|
|
class="inline-flex flex-shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
|
:class="item.badgeClass"
|
|
>
|
|
{{ item.badge }}
|
|
</span>
|
|
</div>
|
|
<div class="mt-1 flex min-w-0 items-baseline gap-2">
|
|
<p class="truncate text-xl font-semibold leading-7 text-gray-900 dark:text-white">{{ item.value }}</p>
|
|
<p v-if="item.meta" class="truncate text-xs text-gray-500 dark:text-gray-400">{{ item.meta }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-4 dark:border-dark-700 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.workerStatus') }}</h2>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.workerStatusHint') }}</p>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
|
<span>{{ t('admin.riskControl.autoRefresh') }}</span>
|
|
<span v-if="status?.last_cleanup_at">
|
|
{{ t('admin.riskControl.lastCleanup', { time: formatDateTime(status.last_cleanup_at) }) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-6 p-6 xl:grid-cols-[minmax(0,360px)_1fr]">
|
|
<div class="space-y-4">
|
|
<div class="rounded-lg border border-gray-100 p-4 dark:border-dark-700">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.riskControl.queueUsage') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{{ formatNumber(status?.queue_length ?? 0) }} / {{ formatNumber(status?.queue_size ?? configForm.queue_size) }}
|
|
</p>
|
|
</div>
|
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ queueUsagePercent }}</span>
|
|
</div>
|
|
<div class="mt-4 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
|
|
<div class="h-full rounded-full bg-primary-500 transition-all duration-300" :style="queueUsageStyle"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="rounded-lg bg-gray-50 p-4 dark:bg-dark-700/50">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.activeWorkers') }}</p>
|
|
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">{{ status?.active_workers ?? 0 }}</p>
|
|
</div>
|
|
<div class="rounded-lg bg-emerald-50 p-4 dark:bg-emerald-900/10">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.idleWorkers') }}</p>
|
|
<p class="mt-2 text-2xl font-semibold text-emerald-700 dark:text-emerald-300">{{ status?.idle_workers ?? configForm.worker_count }}</p>
|
|
</div>
|
|
<div class="rounded-lg bg-gray-50 p-4 dark:bg-dark-700/50">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.processed') }}</p>
|
|
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">{{ formatNumber(status?.processed ?? 0) }}</p>
|
|
</div>
|
|
<div class="rounded-lg bg-gray-50 p-4 dark:bg-dark-700/50">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.droppedErrors') }}</p>
|
|
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">{{ formatNumber((status?.dropped ?? 0) + (status?.errors ?? 0)) }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="mb-3 flex items-center justify-between gap-3">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.riskControl.workerPool') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.riskControl.workerPoolMeta', { active: status?.active_workers ?? 0, idle: status?.idle_workers ?? configForm.worker_count, total: status?.worker_count ?? configForm.worker_count }) }}
|
|
</p>
|
|
</div>
|
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:bg-dark-700 dark:text-gray-300">
|
|
{{ modeLabel(status?.mode ?? configForm.mode) }}
|
|
</span>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
|
<div
|
|
v-for="worker in workerSlots"
|
|
:key="worker.id"
|
|
class="flex h-12 items-center justify-between rounded-lg border px-3 transition-colors"
|
|
:class="workerSlotClass(worker.state)"
|
|
:title="worker.label"
|
|
>
|
|
<span class="text-sm font-semibold">#{{ worker.id }}</span>
|
|
<span class="h-2.5 w-2.5 rounded-full" :class="workerDotClass(worker.state)"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.records') }}</h2>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.recordsHint') }}</p>
|
|
</div>
|
|
<button type="button" class="btn btn-secondary inline-flex items-center gap-2" :disabled="logsLoading" @click="loadLogs">
|
|
<Icon name="refresh" size="sm" :class="logsLoading ? 'animate-spin' : ''" />
|
|
{{ t('admin.riskControl.refresh') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-6">
|
|
<Select v-model="filters.result" :options="resultOptions" @change="reloadLogsFromFirstPage" />
|
|
<Select v-model="filters.group_id" :options="groupFilterOptions" @change="reloadLogsFromFirstPage" />
|
|
<Select v-model="filters.endpoint" :options="endpointOptions" @change="reloadLogsFromFirstPage" />
|
|
<input v-model.trim="filters.search" type="search" class="input" :placeholder="t('admin.riskControl.filters.search')" @keyup.enter="reloadLogsFromFirstPage" />
|
|
<input v-model="filters.from" type="datetime-local" class="input" :title="t('admin.riskControl.filters.from')" @change="reloadLogsFromFirstPage" />
|
|
<input v-model="filters.to" type="datetime-local" class="input" :title="t('admin.riskControl.filters.to')" @change="reloadLogsFromFirstPage" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
|
<thead class="bg-gray-50 dark:bg-dark-800">
|
|
<tr>
|
|
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.time') }}</th>
|
|
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.group') }}</th>
|
|
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.user') }}</th>
|
|
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.apiKey') }}</th>
|
|
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.endpoint') }}</th>
|
|
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.result') }}</th>
|
|
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.highest') }}</th>
|
|
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.actionMeta') }}</th>
|
|
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.latency') }}</th>
|
|
<th class="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.input') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100 bg-white dark:divide-dark-800 dark:bg-dark-800">
|
|
<tr v-if="logsLoading">
|
|
<td colspan="10" class="px-5 py-12 text-center text-sm text-gray-500 dark:text-gray-400">{{ t('common.loading') }}</td>
|
|
</tr>
|
|
<tr v-else-if="logs.length === 0">
|
|
<td colspan="10" class="px-5 py-12 text-center text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.emptyLogs') }}</td>
|
|
</tr>
|
|
<template v-else>
|
|
<tr v-for="row in logs" :key="row.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/60">
|
|
<td class="whitespace-nowrap px-5 py-4 text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(row.created_at) }}</td>
|
|
<td class="whitespace-nowrap px-5 py-4 text-sm text-gray-700 dark:text-gray-300">{{ row.group_name || '-' }}</td>
|
|
<td class="whitespace-nowrap px-5 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
<div>{{ row.user_email || '-' }}</div>
|
|
<div v-if="row.user_id" class="text-xs text-gray-400">UID {{ row.user_id }}</div>
|
|
</td>
|
|
<td class="whitespace-nowrap px-5 py-4 text-sm text-gray-700 dark:text-gray-300">{{ row.api_key_name || '-' }}</td>
|
|
<td class="whitespace-nowrap px-5 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
<div>{{ row.endpoint || '-' }}</div>
|
|
<div class="text-xs text-gray-400">{{ row.provider || '-' }} / {{ row.model || '-' }}</div>
|
|
</td>
|
|
<td class="whitespace-nowrap px-5 py-4">
|
|
<span class="inline-flex rounded-md px-2 py-1 text-xs font-medium" :class="resultBadgeClass(row)">
|
|
{{ resultLabel(row) }}
|
|
</span>
|
|
</td>
|
|
<td class="whitespace-nowrap px-5 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
<div>{{ row.highest_category || '-' }}</div>
|
|
<div class="text-xs text-gray-400">{{ percent(row.highest_score) }}</div>
|
|
</td>
|
|
<td class="whitespace-nowrap px-5 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
<div>{{ violationCountText(row) }}</div>
|
|
<div class="text-xs text-gray-400">
|
|
{{ row.email_sent ? t('admin.riskControl.emailSent') : t('admin.riskControl.emailNotSent') }}
|
|
<span v-if="row.auto_banned"> / {{ t('admin.riskControl.autoBanned') }}</span>
|
|
</div>
|
|
<button
|
|
v-if="canUnbanRow(row)"
|
|
type="button"
|
|
class="mt-2 inline-flex items-center gap-1 rounded-md border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700 transition-colors hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-emerald-900/60 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/30"
|
|
:disabled="unbanningUserID === row.user_id"
|
|
@click="unbanUser(row)"
|
|
>
|
|
<Icon name="checkCircle" size="xs" :class="unbanningUserID === row.user_id ? 'animate-spin' : ''" />
|
|
{{ unbanningUserID === row.user_id ? t('common.processing') : t('admin.riskControl.unbanUser') }}
|
|
</button>
|
|
</td>
|
|
<td class="whitespace-nowrap px-5 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
<div>{{ latencyText(row.upstream_latency_ms) }}</div>
|
|
<div v-if="row.queue_delay_ms !== null && row.queue_delay_ms !== undefined" class="text-xs text-gray-400">
|
|
{{ t('admin.riskControl.queueDelay', { ms: row.queue_delay_ms }) }}
|
|
</div>
|
|
</td>
|
|
<td class="w-[320px] max-w-sm px-5 py-4 text-sm text-gray-700 dark:text-gray-300">
|
|
<button
|
|
type="button"
|
|
class="group flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
|
|
:title="inputSummaryText(row)"
|
|
@click="openInputDetail(row)"
|
|
>
|
|
<span class="min-w-0 flex-1 truncate">{{ inputSummaryText(row) }}</span>
|
|
<Icon name="eye" size="xs" class="flex-shrink-0 text-gray-300 transition-colors group-hover:text-primary-500 dark:text-gray-500" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<Pagination
|
|
v-if="pagination.total > 0"
|
|
:page="pagination.page"
|
|
:total="pagination.total"
|
|
:page-size="pagination.page_size"
|
|
@update:page="onPageChange"
|
|
@update:pageSize="onPageSizeChange"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<BaseDialog :show="settingsOpen" :title="t('admin.riskControl.settingsTitle')" width="extra-wide" @close="settingsOpen = false">
|
|
<div class="space-y-6">
|
|
<div class="flex gap-2 overflow-x-auto border-b border-gray-100 pb-3 dark:border-dark-700">
|
|
<button
|
|
v-for="tab in settingsTabs"
|
|
:key="tab.id"
|
|
type="button"
|
|
class="inline-flex whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors"
|
|
:class="activeSettingsTab === tab.id ? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-white'"
|
|
@click="activeSettingsTab = tab.id"
|
|
>
|
|
{{ tab.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="activeSettingsTab === 'basic'" class="space-y-5">
|
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
|
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-4 dark:border-dark-700">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.riskControl.enabled') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.enabledHint') }}</p>
|
|
</div>
|
|
<Toggle v-model="configForm.enabled" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.mode') }}</label>
|
|
<Select v-model="configForm.mode" :options="modeOptions" />
|
|
<p class="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ modeDescription(configForm.mode) }}</p>
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.baseUrl') }}</label>
|
|
<input v-model.trim="configForm.base_url" type="url" class="input" placeholder="https://api.openai.com" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.model') }}</label>
|
|
<input v-model.trim="configForm.model" type="text" class="input" placeholder="omni-moderation-latest" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.timeoutMs') }}</label>
|
|
<input v-model.number="configForm.timeout_ms" type="number" min="500" max="30000" class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.retryCount') }}</label>
|
|
<input v-model.number="configForm.retry_count" type="number" min="0" max="5" class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.sampleRate') }}</label>
|
|
<div class="relative">
|
|
<input v-model.number="configForm.sample_rate" type="number" min="0" max="100" step="1" class="input pr-8" />
|
|
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-hidden rounded-xl border border-gray-100 bg-white shadow-sm dark:border-dark-700 dark:bg-dark-800">
|
|
<div class="flex flex-col gap-4 border-b border-gray-100 bg-gray-50 px-4 py-4 dark:border-dark-700 dark:bg-dark-800/60 lg:flex-row lg:items-center lg:justify-between">
|
|
<div class="flex items-start gap-3">
|
|
<span class="mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-300">
|
|
<Icon name="key" size="md" />
|
|
</span>
|
|
<div>
|
|
<label class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.apiKeys') }}</label>
|
|
<p class="mt-1 max-w-3xl text-xs leading-5 text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.riskControl.apiKeysHint', { count: configForm.api_key_count }) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary inline-flex items-center gap-2"
|
|
:disabled="apiKeyTesting || inputApiKeyCount === 0 || configForm.clear_api_key"
|
|
@click="testApiKeys(true)"
|
|
>
|
|
<Icon name="beaker" size="sm" :class="apiKeyTesting ? 'animate-pulse' : ''" />
|
|
{{ apiKeyTesting ? t('admin.riskControl.testingApiKeys') : t('admin.riskControl.testInputApiKeys') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary inline-flex items-center gap-2"
|
|
:disabled="apiKeyTesting || effectiveStoredApiKeyCount === 0 || pendingDeletedApiKeyCount > 0 || configForm.clear_api_key || configForm.api_keys_mode === 'replace'"
|
|
@click="testApiKeys(false)"
|
|
>
|
|
<Icon name="shield" size="sm" />
|
|
{{ storedApiKeyTestButtonText }}
|
|
</button>
|
|
<button
|
|
v-if="configForm.api_key_configured"
|
|
type="button"
|
|
class="btn btn-secondary inline-flex items-center gap-2"
|
|
@click="toggleClearApiKey"
|
|
>
|
|
<Icon :name="configForm.clear_api_key ? 'x' : 'trash'" size="sm" />
|
|
{{ configForm.clear_api_key ? t('admin.riskControl.keepApiKey') : t('admin.riskControl.clearApiKey') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-4 p-4 xl:grid-cols-[minmax(0,1fr)_minmax(360px,440px)]">
|
|
<div class="space-y-3">
|
|
<div class="flex flex-col gap-2 rounded-lg border border-gray-100 bg-gray-50 p-2 dark:border-dark-700 dark:bg-dark-900/30 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
|
<span class="font-medium text-gray-700 dark:text-gray-200">{{ t('admin.riskControl.apiKeysWriteMode') }}</span>
|
|
<span class="ml-2">{{ apiKeysModeHint }}</span>
|
|
</div>
|
|
<div class="inline-flex rounded-lg bg-white p-1 shadow-sm dark:bg-dark-800">
|
|
<button
|
|
type="button"
|
|
class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
|
|
:class="configForm.api_keys_mode === 'append' ? 'bg-primary-500 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700'"
|
|
:disabled="configForm.clear_api_key"
|
|
@click="setAPIKeysMode('append')"
|
|
>
|
|
{{ t('admin.riskControl.apiKeysModeAppend') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors"
|
|
:class="configForm.api_keys_mode === 'replace' ? 'bg-amber-500 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700'"
|
|
:disabled="configForm.clear_api_key"
|
|
@click="setAPIKeysMode('replace')"
|
|
>
|
|
{{ t('admin.riskControl.apiKeysModeReplace') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<textarea
|
|
v-model="configForm.api_keys_text"
|
|
class="input min-h-44 resize-y font-mono text-sm"
|
|
:placeholder="apiKeysPlaceholder"
|
|
autocomplete="new-password"
|
|
:disabled="configForm.clear_api_key"
|
|
></textarea>
|
|
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
|
<span class="inline-flex rounded-md bg-gray-100 px-2 py-1 dark:bg-dark-700">
|
|
{{ t('admin.riskControl.inputApiKeyCount', { count: inputApiKeyCount }) }}
|
|
</span>
|
|
<span v-if="configForm.api_key_configured" class="inline-flex rounded-md bg-gray-100 px-2 py-1 dark:bg-dark-700">
|
|
{{ t('admin.riskControl.storedApiKeyCount', { count: configForm.api_key_count }) }}
|
|
</span>
|
|
<span v-if="configForm.clear_api_key" class="inline-flex rounded-md bg-red-50 px-2 py-1 text-red-700 dark:bg-red-900/20 dark:text-red-300">
|
|
{{ t('admin.riskControl.apiKeyWillClear') }}
|
|
</span>
|
|
<span v-else-if="pendingDeletedApiKeyCount > 0" class="inline-flex rounded-md bg-amber-50 px-2 py-1 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
|
{{ t('admin.riskControl.apiKeyPendingDeleteCount', { count: pendingDeletedApiKeyCount }) }}
|
|
</span>
|
|
<span v-if="configForm.api_keys_mode === 'replace'" class="inline-flex rounded-md bg-amber-50 px-2 py-1 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
|
{{ t('admin.riskControl.apiKeysReplaceWarning') }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900/30" @paste="handleModerationImagePaste">
|
|
<div class="mb-3 flex items-center justify-between gap-3">
|
|
<div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.auditTestInput') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.auditTestInputHint') }}</p>
|
|
</div>
|
|
<button
|
|
v-if="moderationTestPrompt || moderationTestImages.length > 0 || moderationTestResult"
|
|
type="button"
|
|
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-gray-500 hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-dark-800 dark:hover:text-white"
|
|
@click="clearModerationTestInput"
|
|
>
|
|
<Icon name="x" size="xs" />
|
|
{{ t('admin.riskControl.clearAuditTest') }}
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
v-model="moderationTestPrompt"
|
|
class="input min-h-24 resize-y text-sm"
|
|
:placeholder="t('admin.riskControl.auditTestPromptPlaceholder')"
|
|
></textarea>
|
|
<div
|
|
class="mt-3 rounded-lg border border-dashed border-gray-200 bg-white p-3 dark:border-dark-700 dark:bg-dark-800"
|
|
@dragover.prevent
|
|
@drop.prevent="handleModerationImageDrop"
|
|
>
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="flex items-start gap-2">
|
|
<Icon name="upload" size="md" class="mt-0.5 text-gray-400" />
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">{{ t('admin.riskControl.auditTestImages') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.auditTestImagesHint') }}</p>
|
|
</div>
|
|
</div>
|
|
<label class="btn btn-secondary inline-flex cursor-pointer items-center gap-2">
|
|
<Icon name="plus" size="sm" />
|
|
{{ t('admin.riskControl.addAuditTestImage') }}
|
|
<input type="file" accept="image/*" multiple class="sr-only" @change="handleModerationImageUpload" />
|
|
</label>
|
|
</div>
|
|
<div v-if="moderationTestImages.length > 0" class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
<div
|
|
v-for="(image, index) in moderationTestImages"
|
|
:key="image.slice(0, 64) + index"
|
|
class="group relative aspect-square overflow-hidden rounded-lg border border-gray-100 bg-gray-100 dark:border-dark-700 dark:bg-dark-700"
|
|
>
|
|
<img :src="image" alt="" class="h-full w-full object-cover" />
|
|
<button
|
|
type="button"
|
|
class="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
|
@click="removeModerationTestImage(index)"
|
|
>
|
|
<Icon name="x" size="xs" :stroke-width="2" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900/30">
|
|
<div class="mb-3 flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.apiKeyHealth') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.apiKeyFreezeRule') }}</p>
|
|
</div>
|
|
<span class="inline-flex shrink-0 items-center whitespace-nowrap rounded-full bg-white px-2 py-0.5 text-[11px] font-medium leading-5 text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300">
|
|
{{ t('admin.riskControl.apiKeyRows', { count: apiKeyRows.length }) }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="apiKeyRows.length === 0" class="flex min-h-32 flex-col items-center justify-center rounded-lg border border-dashed border-gray-200 bg-white px-4 py-6 text-center dark:border-dark-700 dark:bg-dark-800">
|
|
<Icon name="infoCircle" size="lg" class="text-gray-300 dark:text-dark-500" />
|
|
<p class="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">{{ t('admin.riskControl.apiKeyHealthEmpty') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.apiKeyHealthEmptyHint') }}</p>
|
|
</div>
|
|
<div v-else class="space-y-2">
|
|
<div class="space-y-2" :class="apiKeyRowsExpanded ? 'max-h-72 overflow-y-auto pr-1' : ''">
|
|
<div
|
|
v-for="(row, index) in visibleApiKeyRows"
|
|
:key="apiKeyRowKey(row, index)"
|
|
class="rounded-lg border bg-white p-2.5 shadow-sm dark:bg-dark-800"
|
|
:class="isStoredApiKeyPendingDelete(row) ? 'border-amber-200 opacity-70 dark:border-amber-800/60' : 'border-gray-100 dark:border-dark-700'"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
|
<span class="truncate font-mono text-sm font-semibold text-gray-900 dark:text-white">{{ row.masked || '-' }}</span>
|
|
<span
|
|
class="inline-flex rounded-md px-1.5 py-0.5 text-[11px] font-medium"
|
|
:class="row.configured ? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' : 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'"
|
|
>
|
|
{{ isStoredApiKeyPendingDelete(row) ? t('admin.riskControl.apiKeyPendingDelete') : row.configured ? t('admin.riskControl.apiKeyConfigured') : t('admin.riskControl.apiKeyTemporary') }}
|
|
</span>
|
|
</div>
|
|
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">{{ apiKeyStatusMeta(row) }}</p>
|
|
</div>
|
|
<div class="flex flex-shrink-0 items-center gap-1.5">
|
|
<span class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium" :class="apiKeyStatusBadgeClass(row.status)">
|
|
<span class="h-1.5 w-1.5 rounded-full" :class="apiKeyStatusDotClass(row.status)"></span>
|
|
{{ apiKeyStatusLabel(row.status) }}
|
|
</span>
|
|
<button
|
|
v-if="row.configured && !configForm.clear_api_key"
|
|
type="button"
|
|
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dark-700 dark:hover:text-gray-200"
|
|
:title="isStoredApiKeyPendingDelete(row) ? t('admin.riskControl.undoDeleteApiKey') : t('admin.riskControl.deleteApiKey')"
|
|
@click="toggleDeleteStoredApiKey(row)"
|
|
>
|
|
<Icon :name="isStoredApiKeyPendingDelete(row) ? 'refresh' : 'trash'" size="xs" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p v-if="row.last_error" class="mt-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-xs leading-5 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
|
{{ row.last_error }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="canToggleApiKeyRows" class="flex items-center justify-between gap-3 rounded-lg border border-dashed border-gray-200 bg-white px-3 py-2 text-xs text-gray-500 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400">
|
|
<span class="min-w-0 truncate">
|
|
{{ apiKeyRowsExpanded ? t('admin.riskControl.apiKeyRowsExpanded', { count: apiKeyRows.length }) : t('admin.riskControl.apiKeyRowsCollapsed', { count: hiddenApiKeyRowCount }) }}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
class="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 font-medium text-primary-600 transition-colors hover:bg-primary-50 hover:text-primary-700 dark:text-primary-300 dark:hover:bg-primary-900/20"
|
|
@click="apiKeyRowsExpanded = !apiKeyRowsExpanded"
|
|
>
|
|
<Icon :name="apiKeyRowsExpanded ? 'chevronUp' : 'chevronDown'" size="xs" />
|
|
{{ apiKeyRowsExpanded ? t('admin.riskControl.collapseApiKeyRows') : t('admin.riskControl.expandApiKeyRows') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="moderationTestResult" class="mt-4 rounded-lg border border-gray-100 bg-white p-3 dark:border-dark-700 dark:bg-dark-800">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.auditTestResult') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{{ t('admin.riskControl.auditTestHighest', { category: moderationTestResult.highest_category || '-', score: percent(moderationTestResult.highest_score) }) }}
|
|
</p>
|
|
</div>
|
|
<span class="inline-flex rounded-full px-2 py-1 text-xs font-medium" :class="moderationTestResult.flagged ? 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300' : 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300'">
|
|
{{ moderationTestResult.flagged ? t('admin.riskControl.auditTestFlagged') : t('admin.riskControl.auditTestPassed') }}
|
|
</span>
|
|
</div>
|
|
<div class="mt-3">
|
|
<div class="mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
|
<span>{{ t('admin.riskControl.auditTestComposite') }}</span>
|
|
<span class="font-semibold text-gray-900 dark:text-white">{{ percent(moderationTestResult.composite_score) }}</span>
|
|
</div>
|
|
<div class="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
|
|
<div class="h-full rounded-full" :class="moderationTestResult.flagged ? 'bg-red-500' : 'bg-emerald-500'" :style="{ width: percentWidth(moderationTestResult.composite_score) }"></div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 max-h-52 space-y-2 overflow-y-auto pr-1">
|
|
<div v-for="score in moderationScoreRows" :key="score.category">
|
|
<div class="mb-1 flex items-center justify-between gap-3 text-xs">
|
|
<span class="truncate text-gray-600 dark:text-gray-300">{{ score.category }}</span>
|
|
<span class="font-mono text-gray-500 dark:text-gray-400">{{ percent(score.score) }} / {{ percent(score.threshold) }}</span>
|
|
</div>
|
|
<div class="h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
|
|
<div class="h-full rounded-full" :class="score.hit ? 'bg-red-500' : 'bg-primary-500'" :style="{ width: percentWidth(score.score) }"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="activeSettingsTab === 'scope'" class="space-y-5">
|
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.groupScope') }}</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.groupScopeHint') }}</p>
|
|
</div>
|
|
<div class="inline-flex rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
|
|
<button
|
|
type="button"
|
|
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
|
|
:class="configForm.all_groups ? 'bg-white text-gray-900 shadow-sm dark:bg-dark-800 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
|
|
@click="configForm.all_groups = true"
|
|
>
|
|
{{ t('admin.riskControl.allGroups') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
|
|
:class="!configForm.all_groups ? 'bg-white text-gray-900 shadow-sm dark:bg-dark-800 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
|
|
@click="configForm.all_groups = false"
|
|
>
|
|
{{ t('admin.riskControl.selectedGroups') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!configForm.all_groups" class="space-y-4">
|
|
<div class="relative">
|
|
<Icon name="search" size="sm" class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
<input v-model.trim="groupSearch" type="search" class="input pl-9" :placeholder="t('admin.riskControl.searchGroups')" />
|
|
</div>
|
|
<div class="grid max-h-[420px] grid-cols-1 gap-3 overflow-y-auto pr-1 md:grid-cols-2 xl:grid-cols-3">
|
|
<button
|
|
v-for="group in filteredGroups"
|
|
:key="group.id"
|
|
type="button"
|
|
class="flex min-h-20 items-center justify-between rounded-lg border p-4 text-left transition-colors"
|
|
:class="isGroupSelected(group.id) ? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/20' : 'border-gray-100 hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-700/60'"
|
|
@click="toggleGroup(group.id)"
|
|
>
|
|
<span class="min-w-0">
|
|
<span class="block truncate text-sm font-semibold text-gray-900 dark:text-white">{{ group.name }}</span>
|
|
<span class="mt-1 inline-flex rounded-md bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-dark-700 dark:text-gray-400">{{ group.platform }}</span>
|
|
</span>
|
|
<span
|
|
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border"
|
|
:class="isGroupSelected(group.id) ? 'border-primary-500 bg-primary-500 text-white' : 'border-gray-300 text-transparent dark:border-dark-500'"
|
|
>
|
|
<Icon name="check" size="xs" :stroke-width="2" />
|
|
</span>
|
|
</button>
|
|
<p v-if="filteredGroups.length === 0" class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.noGroups') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="activeSettingsTab === 'runtime'" class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.workerCount') }}</label>
|
|
<input v-model.number="configForm.worker_count" type="number" min="1" max="32" class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.queueSize') }}</label>
|
|
<input v-model.number="configForm.queue_size" type="number" min="100" max="100000" class="input" />
|
|
</div>
|
|
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-4 dark:border-dark-700 lg:col-span-2">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.riskControl.recordNonHits') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.recordNonHitsHint') }}</p>
|
|
</div>
|
|
<Toggle v-model="configForm.record_non_hits" />
|
|
</div>
|
|
<div class="space-y-4 rounded-lg border border-gray-100 p-4 dark:border-dark-700 lg:col-span-2">
|
|
<div class="flex items-center justify-between gap-4">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.riskControl.preHashCheck') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.preHashCheckHint') }}</p>
|
|
</div>
|
|
<Toggle v-model="configForm.pre_hash_check_enabled" />
|
|
</div>
|
|
<div class="rounded-lg bg-gray-50 p-3 dark:bg-dark-900/30">
|
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{{ t('admin.riskControl.flaggedHashCount', { count: formatNumber(status?.flagged_hash_count ?? 0) }) }}
|
|
</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.flaggedHashHint') }}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary inline-flex items-center justify-center gap-2 text-red-600 hover:text-red-700 dark:text-red-300"
|
|
:disabled="hashActionLoading || (status?.flagged_hash_count ?? 0) === 0"
|
|
@click="clearFlaggedHashes"
|
|
>
|
|
<Icon name="trash" size="sm" :class="hashActionLoading ? 'animate-pulse' : ''" />
|
|
{{ t('admin.riskControl.clearFlaggedHashes') }}
|
|
</button>
|
|
</div>
|
|
<div class="mt-3 flex flex-col gap-2 sm:flex-row">
|
|
<input
|
|
v-model.trim="flaggedHashInput"
|
|
type="text"
|
|
class="input font-mono text-sm"
|
|
:placeholder="t('admin.riskControl.flaggedHashPlaceholder')"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary inline-flex items-center justify-center gap-2"
|
|
:disabled="hashActionLoading || !isFlaggedHashInputValid"
|
|
@click="deleteFlaggedHash"
|
|
>
|
|
<Icon name="trash" size="sm" />
|
|
{{ t('admin.riskControl.deleteFlaggedHash') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="activeSettingsTab === 'response'" class="space-y-5">
|
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.blockStatus') }}</label>
|
|
<input v-model.number="configForm.block_status" type="number" min="400" max="599" class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.blockMessage') }}</label>
|
|
<input v-model.trim="configForm.block_message" type="text" class="input" />
|
|
</div>
|
|
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-4 dark:border-dark-700">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.riskControl.emailOnHit') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.emailOnHitHint') }}</p>
|
|
</div>
|
|
<Toggle v-model="configForm.email_on_hit" />
|
|
</div>
|
|
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-4 dark:border-dark-700">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.riskControl.autoBan') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.autoBanHint') }}</p>
|
|
</div>
|
|
<Toggle v-model="configForm.auto_ban_enabled" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.banThreshold') }}</label>
|
|
<input v-model.number="configForm.ban_threshold" type="number" min="1" max="1000" class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.violationWindowHours') }}</label>
|
|
<input v-model.number="configForm.violation_window_hours" type="number" min="1" max="8760" class="input" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.hitRetentionDays') }}</label>
|
|
<input v-model.number="configForm.hit_retention_days" type="number" min="1" max="3650" class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('admin.riskControl.nonHitRetentionDays') }}</label>
|
|
<input v-model.number="configForm.non_hit_retention_days" type="number" min="1" max="3" class="input" />
|
|
</div>
|
|
<div class="rounded-lg border border-gray-100 p-4 text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400 lg:col-span-2">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<Icon name="database" size="md" class="text-gray-400" />
|
|
<span>{{ t('admin.riskControl.cleanupStats', { hit: status?.last_cleanup_deleted_hit ?? 0, nonHit: status?.last_cleanup_deleted_non_hit ?? 0 }) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<button type="button" class="btn btn-secondary" @click="settingsOpen = false">{{ t('common.cancel') }}</button>
|
|
<button type="button" class="btn btn-primary inline-flex items-center gap-2" :disabled="saving" @click="saveConfig">
|
|
<Icon v-if="saving" name="refresh" size="sm" class="animate-spin" />
|
|
<Icon v-else name="check" size="sm" />
|
|
{{ saving ? t('common.saving') : t('admin.riskControl.saveConfig') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</BaseDialog>
|
|
|
|
<BaseDialog
|
|
:show="inputDetailRow !== null"
|
|
:title="t('admin.riskControl.inputDetailTitle')"
|
|
width="wide"
|
|
@close="closeInputDetail"
|
|
>
|
|
<div v-if="inputDetailRow" class="space-y-5">
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/70">
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.time') }}</p>
|
|
<p class="mt-1 truncate text-sm font-semibold text-gray-900 dark:text-white">{{ formatDateTime(inputDetailRow.created_at) }}</p>
|
|
</div>
|
|
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/70">
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.user') }}</p>
|
|
<p class="mt-1 truncate text-sm font-semibold text-gray-900 dark:text-white">{{ inputDetailRow.user_email || '-' }}</p>
|
|
</div>
|
|
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/70">
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.result') }}</p>
|
|
<span class="mt-1 inline-flex rounded-md px-2 py-1 text-xs font-medium" :class="resultBadgeClass(inputDetailRow)">
|
|
{{ resultLabel(inputDetailRow) }}
|
|
</span>
|
|
</div>
|
|
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/70">
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.table.highest') }}</p>
|
|
<p class="mt-1 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
|
{{ inputDetailRow.highest_category || '-' }} / {{ percent(inputDetailRow.highest_score) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-xl border border-gray-100 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800">
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.riskControl.inputDetailContent') }}</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{{ inputDetailRow.endpoint || '-' }} · {{ inputDetailRow.provider || '-' }} / {{ inputDetailRow.model || '-' }}
|
|
</p>
|
|
</div>
|
|
<span v-if="inputDetailRow.group_name" class="inline-flex rounded-md bg-sky-50 px-2.5 py-1 text-xs font-medium text-sky-700 dark:bg-sky-900/20 dark:text-sky-300">
|
|
{{ inputDetailRow.group_name }}
|
|
</span>
|
|
</div>
|
|
<pre class="mt-4 max-h-[420px] overflow-auto whitespace-pre-wrap break-words rounded-lg bg-gray-950 p-4 text-sm leading-6 text-gray-100 shadow-inner dark:bg-black/50">{{ inputDetailText }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end">
|
|
<button type="button" class="btn btn-secondary" @click="closeInputDetail">{{ t('common.close') }}</button>
|
|
</div>
|
|
</template>
|
|
</BaseDialog>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import Select from '@/components/common/Select.vue'
|
|
import Toggle from '@/components/common/Toggle.vue'
|
|
import Pagination from '@/components/common/Pagination.vue'
|
|
import { adminAPI } from '@/api/admin'
|
|
import type {
|
|
ContentModerationAPIKeyStatus,
|
|
ContentModerationConfig,
|
|
ContentModerationLog,
|
|
ContentModerationRuntimeStatus,
|
|
ContentModerationTestAuditResult,
|
|
ModerationMode,
|
|
UpdateContentModerationConfig,
|
|
} from '@/api/admin/riskControl'
|
|
import type { AdminGroup, SelectOption } from '@/types'
|
|
import { useAppStore } from '@/stores/app'
|
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
|
import { formatDateTime as formatDateTimeValue } from '@/utils/format'
|
|
|
|
type SettingsTab = 'basic' | 'scope' | 'runtime' | 'response' | 'retention'
|
|
type WorkerSlotState = 'active' | 'idle' | 'disabled'
|
|
type APIKeysWriteMode = 'append' | 'replace'
|
|
type OverviewIcon = 'shield' | 'key' | 'users' | 'document'
|
|
type OverviewItem = {
|
|
key: string
|
|
label: string
|
|
value: string
|
|
meta: string
|
|
icon: OverviewIcon
|
|
iconClass: string
|
|
badge?: string
|
|
badgeClass?: string
|
|
}
|
|
type ModerationScoreRow = {
|
|
category: string
|
|
score: number
|
|
threshold: number
|
|
hit: boolean
|
|
}
|
|
|
|
const maxModerationTestImages = 1
|
|
const maxModerationTestImageSize = 8 * 1024 * 1024
|
|
const maxVisibleApiKeyRows: number = 3
|
|
|
|
const { t } = useI18n()
|
|
const appStore = useAppStore()
|
|
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const logsLoading = ref(false)
|
|
const statusLoading = ref(false)
|
|
const apiKeyTesting = ref(false)
|
|
const hashActionLoading = ref(false)
|
|
const unbanningUserID = ref<number | null>(null)
|
|
const settingsOpen = ref(false)
|
|
const activeSettingsTab = ref<SettingsTab>('basic')
|
|
const groupSearch = ref('')
|
|
const flaggedHashInput = ref('')
|
|
const groups = ref<AdminGroup[]>([])
|
|
const logs = ref<ContentModerationLog[]>([])
|
|
const status = ref<ContentModerationRuntimeStatus | null>(null)
|
|
const testedApiKeyStatuses = ref<ContentModerationAPIKeyStatus[]>([])
|
|
const pendingDeleteApiKeyHashes = ref<string[]>([])
|
|
const apiKeyRowsExpanded = ref<boolean>(false)
|
|
const moderationTestPrompt = ref('')
|
|
const moderationTestImages = ref<string[]>([])
|
|
const moderationTestResult = ref<ContentModerationTestAuditResult | null>(null)
|
|
const inputDetailRow = ref<ContentModerationLog | null>(null)
|
|
let statusTimer: number | null = null
|
|
|
|
const configForm = reactive({
|
|
enabled: false,
|
|
mode: 'pre_block' as ModerationMode,
|
|
base_url: 'https://api.openai.com',
|
|
model: 'omni-moderation-latest',
|
|
api_keys_text: '',
|
|
api_key_configured: false,
|
|
api_key_masked: '',
|
|
api_key_count: 0,
|
|
api_key_masks: [] as string[],
|
|
api_key_statuses: [] as ContentModerationAPIKeyStatus[],
|
|
api_keys_mode: 'append' as APIKeysWriteMode,
|
|
clear_api_key: false,
|
|
timeout_ms: 3000,
|
|
retry_count: 2,
|
|
sample_rate: 100,
|
|
all_groups: true,
|
|
group_ids: [] as number[],
|
|
record_non_hits: false,
|
|
worker_count: 4,
|
|
queue_size: 32768,
|
|
block_status: 403,
|
|
block_message: '内容审计命中风险规则,请调整输入后重试',
|
|
email_on_hit: true,
|
|
auto_ban_enabled: true,
|
|
ban_threshold: 10,
|
|
violation_window_hours: 720,
|
|
hit_retention_days: 180,
|
|
non_hit_retention_days: 3,
|
|
pre_hash_check_enabled: false,
|
|
})
|
|
|
|
const pagination = reactive({
|
|
page: 1,
|
|
page_size: 20,
|
|
total: 0,
|
|
pages: 1,
|
|
})
|
|
|
|
const filters = reactive({
|
|
result: '',
|
|
group_id: 0,
|
|
endpoint: '',
|
|
search: '',
|
|
from: '',
|
|
to: '',
|
|
})
|
|
|
|
const settingsTabs = computed<Array<{ id: SettingsTab; label: string }>>(() => [
|
|
{ id: 'basic', label: t('admin.riskControl.tabs.basic') },
|
|
{ id: 'scope', label: t('admin.riskControl.tabs.scope') },
|
|
{ id: 'runtime', label: t('admin.riskControl.tabs.runtime') },
|
|
{ id: 'response', label: t('admin.riskControl.tabs.response') },
|
|
{ id: 'retention', label: t('admin.riskControl.tabs.retention') },
|
|
])
|
|
|
|
const modeOptions = computed<SelectOption[]>(() => [
|
|
{ value: 'pre_block', label: t('admin.riskControl.modePreBlock') },
|
|
{ value: 'observe', label: t('admin.riskControl.modeObserve') },
|
|
{ value: 'off', label: t('admin.riskControl.modeOff') },
|
|
])
|
|
|
|
const resultOptions = computed<SelectOption[]>(() => [
|
|
{ value: '', label: t('admin.riskControl.result.all') },
|
|
{ value: 'hit', label: t('admin.riskControl.result.hit') },
|
|
{ value: 'blocked', label: t('admin.riskControl.result.blocked') },
|
|
{ value: 'pass', label: t('admin.riskControl.result.pass') },
|
|
{ value: 'error', label: t('admin.riskControl.result.error') },
|
|
])
|
|
|
|
const endpointOptions = computed<SelectOption[]>(() => [
|
|
{ value: '', label: t('admin.riskControl.filters.allEndpoints') },
|
|
{ value: '/v1/messages', label: '/v1/messages' },
|
|
{ value: '/v1/responses', label: '/v1/responses' },
|
|
{ value: '/v1/chat/completions', label: '/v1/chat/completions' },
|
|
{ value: '/v1beta/models', label: '/v1beta/models' },
|
|
{ value: '/v1/images/generations', label: '/v1/images/generations' },
|
|
{ value: '/v1/images/edits', label: '/v1/images/edits' },
|
|
])
|
|
|
|
const groupFilterOptions = computed<SelectOption[]>(() => [
|
|
{ value: 0, label: t('admin.riskControl.filters.allGroups') },
|
|
...groups.value.map((group) => ({
|
|
value: group.id,
|
|
label: `${group.name} (${group.platform})`,
|
|
})),
|
|
])
|
|
|
|
const selectedGroupCount = computed(() => String(configForm.group_ids.length))
|
|
|
|
const filteredGroups = computed(() => {
|
|
const keyword = groupSearch.value.trim().toLowerCase()
|
|
if (!keyword) return groups.value
|
|
return groups.value.filter((group) => {
|
|
return group.name.toLowerCase().includes(keyword) || String(group.platform).toLowerCase().includes(keyword)
|
|
})
|
|
})
|
|
|
|
const inputApiKeyCount = computed(() => parseApiKeys(configForm.api_keys_text).length)
|
|
|
|
const pendingDeletedApiKeyCount = computed(() => pendingDeleteApiKeyHashes.value.length)
|
|
|
|
const effectiveStoredApiKeyCount = computed(() => Math.max(0, configForm.api_key_count - pendingDeletedApiKeyCount.value))
|
|
|
|
const apiKeysPlaceholder = computed(() => (
|
|
configForm.api_keys_mode === 'replace'
|
|
? t('admin.riskControl.apiKeysPlaceholderReplace')
|
|
: t('admin.riskControl.apiKeysPlaceholder')
|
|
))
|
|
|
|
const apiKeysModeHint = computed(() => (
|
|
configForm.api_keys_mode === 'replace'
|
|
? t('admin.riskControl.apiKeysModeReplaceHint')
|
|
: t('admin.riskControl.apiKeysModeAppendHint')
|
|
))
|
|
|
|
const hasModerationAuditInput = computed(() => {
|
|
return moderationTestPrompt.value.trim() !== '' || moderationTestImages.value.length > 0
|
|
})
|
|
|
|
const isFlaggedHashInputValid = computed(() => /^[a-fA-F0-9]{64}$/.test(flaggedHashInput.value.trim()))
|
|
|
|
const storedApiKeyTestButtonText = computed(() => {
|
|
if (apiKeyTesting.value) return t('admin.riskControl.testingApiKeys')
|
|
if (hasModerationAuditInput.value) return t('admin.riskControl.testContentWithStoredApiKey')
|
|
return t('admin.riskControl.testStoredApiKeys')
|
|
})
|
|
|
|
const savedApiKeyRows = computed<ContentModerationAPIKeyStatus[]>(() => {
|
|
const rows = status.value?.api_key_statuses?.length
|
|
? status.value.api_key_statuses
|
|
: configForm.api_key_statuses
|
|
return Array.isArray(rows) ? rows : []
|
|
})
|
|
|
|
const apiKeyRows = computed<ContentModerationAPIKeyStatus[]>(() => [
|
|
...savedApiKeyRows.value,
|
|
...testedApiKeyStatuses.value,
|
|
])
|
|
|
|
const visibleApiKeyRows = computed<ContentModerationAPIKeyStatus[]>(() => {
|
|
if (apiKeyRowsExpanded.value) return apiKeyRows.value
|
|
return apiKeyRows.value.slice(0, maxVisibleApiKeyRows)
|
|
})
|
|
|
|
const hiddenApiKeyRowCount = computed<number>(() => Math.max(0, apiKeyRows.value.length - visibleApiKeyRows.value.length))
|
|
|
|
const canToggleApiKeyRows = computed<boolean>(() => apiKeyRows.value.length > maxVisibleApiKeyRows)
|
|
|
|
const activeSavedApiKeyRows = computed<ContentModerationAPIKeyStatus[]>(() => (
|
|
savedApiKeyRows.value.filter((row) => !isStoredApiKeyPendingDelete(row))
|
|
))
|
|
|
|
const apiKeyHealthBadges = computed<Array<{ status: ContentModerationAPIKeyStatus['status']; count: number }>>(() => {
|
|
const counts: Record<ContentModerationAPIKeyStatus['status'], number> = {
|
|
ok: 0,
|
|
error: 0,
|
|
frozen: 0,
|
|
unknown: 0,
|
|
}
|
|
for (const row of activeSavedApiKeyRows.value) {
|
|
counts[row.status] = (counts[row.status] ?? 0) + 1
|
|
}
|
|
if (activeSavedApiKeyRows.value.length === 0 && effectiveStoredApiKeyCount.value > 0) {
|
|
counts.unknown = effectiveStoredApiKeyCount.value
|
|
}
|
|
return (['ok', 'frozen', 'error', 'unknown'] as Array<ContentModerationAPIKeyStatus['status']>)
|
|
.map((item) => ({ status: item, count: counts[item] }))
|
|
.filter((item) => item.count > 0)
|
|
})
|
|
|
|
const apiKeyHealthSummary = computed(() => {
|
|
if (!configForm.api_key_configured) return ''
|
|
if (apiKeyHealthBadges.value.length === 0) return t('admin.riskControl.apiKeyStatusUnknown')
|
|
return apiKeyHealthBadges.value
|
|
.map((badge) => `${apiKeyStatusLabel(badge.status)} ${badge.count}`)
|
|
.join(' · ')
|
|
})
|
|
|
|
const overviewItems = computed<OverviewItem[]>(() => [
|
|
{
|
|
key: 'status',
|
|
label: t('admin.riskControl.overview.status'),
|
|
value: configForm.enabled ? t('admin.riskControl.overview.enabled') : t('admin.riskControl.overview.disabled'),
|
|
meta: modeLabel(configForm.mode),
|
|
icon: 'shield',
|
|
iconClass: configForm.enabled
|
|
? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-300'
|
|
: 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400',
|
|
badge: runtimeBadgeText.value,
|
|
badgeClass: runtimeBadgeClass.value,
|
|
},
|
|
{
|
|
key: 'api-key',
|
|
label: t('admin.riskControl.overview.apiKey'),
|
|
value: configForm.api_key_configured ? t('admin.riskControl.apiKeyCount', { count: configForm.api_key_count }) : t('admin.riskControl.notConfigured'),
|
|
meta: configForm.api_key_configured ? apiKeyHealthSummary.value || configForm.model || '-' : configForm.model || '-',
|
|
icon: 'key',
|
|
iconClass: 'bg-sky-50 text-sky-600 dark:bg-sky-900/20 dark:text-sky-300',
|
|
},
|
|
{
|
|
key: 'scope',
|
|
label: t('admin.riskControl.overview.groupScope'),
|
|
value: configForm.all_groups ? t('admin.riskControl.allGroups') : selectedGroupCount.value,
|
|
meta: configForm.all_groups ? t('admin.riskControl.allGroupsHint') : t('admin.riskControl.selectedGroupsHint'),
|
|
icon: 'users',
|
|
iconClass: 'bg-violet-50 text-violet-600 dark:bg-violet-900/20 dark:text-violet-300',
|
|
},
|
|
{
|
|
key: 'logs',
|
|
label: t('admin.riskControl.overview.logs'),
|
|
value: formatNumber(pagination.total),
|
|
meta: t('admin.riskControl.overview.currentFilter'),
|
|
icon: 'document',
|
|
iconClass: 'bg-amber-50 text-amber-600 dark:bg-amber-900/20 dark:text-amber-300',
|
|
},
|
|
])
|
|
|
|
const moderationScoreRows = computed<ModerationScoreRow[]>(() => {
|
|
const result = moderationTestResult.value
|
|
if (!result) return []
|
|
return Object.entries(result.category_scores || {})
|
|
.map(([category, score]) => {
|
|
const threshold = result.thresholds?.[category] ?? 1
|
|
return {
|
|
category,
|
|
score,
|
|
threshold,
|
|
hit: score >= threshold,
|
|
}
|
|
})
|
|
.sort((a, b) => b.score - a.score)
|
|
})
|
|
|
|
const inputDetailText = computed(() => {
|
|
if (!inputDetailRow.value) return '-'
|
|
return inputDetailRow.value.input_excerpt || inputDetailRow.value.error || '-'
|
|
})
|
|
|
|
const queueUsagePercent = computed(() => `${Math.min(100, Math.max(0, status.value?.queue_usage_percent ?? 0)).toFixed(1)}%`)
|
|
|
|
const queueUsageStyle = computed(() => ({
|
|
width: queueUsagePercent.value,
|
|
}))
|
|
|
|
const workerSlots = computed(() => {
|
|
const total = Math.max(0, status.value?.worker_count ?? configForm.worker_count)
|
|
const active = Math.max(0, status.value?.active_workers ?? 0)
|
|
const enabled = Boolean(status.value?.risk_control_enabled && status.value?.enabled && status.value?.mode !== 'off')
|
|
return Array.from({ length: total }, (_, index) => ({
|
|
id: index + 1,
|
|
state: (!enabled ? 'disabled' : index < active ? 'active' : 'idle') as WorkerSlotState,
|
|
label: !enabled
|
|
? t('admin.riskControl.workerDisabled')
|
|
: index < active
|
|
? t('admin.riskControl.workerActive')
|
|
: t('admin.riskControl.workerIdle'),
|
|
}))
|
|
})
|
|
|
|
const runtimeBadgeText = computed(() => {
|
|
if (!status.value?.risk_control_enabled) return t('admin.riskControl.riskSwitchOff')
|
|
if (!configForm.enabled || configForm.mode === 'off') return t('admin.riskControl.overview.disabled')
|
|
return t('admin.riskControl.overview.enabled')
|
|
})
|
|
|
|
const runtimeBadgeClass = computed(() => {
|
|
if (!status.value?.risk_control_enabled || !configForm.enabled || configForm.mode === 'off') {
|
|
return 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
|
|
}
|
|
return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300'
|
|
})
|
|
|
|
function applyConfig(config: ContentModerationConfig) {
|
|
configForm.enabled = config.enabled
|
|
configForm.mode = config.mode
|
|
configForm.base_url = config.base_url || 'https://api.openai.com'
|
|
configForm.model = config.model || 'omni-moderation-latest'
|
|
configForm.api_keys_text = ''
|
|
configForm.api_key_configured = config.api_key_configured
|
|
configForm.api_key_masked = config.api_key_masked || ''
|
|
configForm.api_key_count = config.api_key_count || 0
|
|
configForm.api_key_masks = Array.isArray(config.api_key_masks) ? [...config.api_key_masks] : []
|
|
configForm.api_key_statuses = Array.isArray(config.api_key_statuses) ? [...config.api_key_statuses] : []
|
|
configForm.api_keys_mode = 'append'
|
|
configForm.clear_api_key = false
|
|
pendingDeleteApiKeyHashes.value = []
|
|
testedApiKeyStatuses.value = []
|
|
apiKeyRowsExpanded.value = false
|
|
configForm.timeout_ms = config.timeout_ms || 3000
|
|
configForm.retry_count = config.retry_count ?? 2
|
|
configForm.sample_rate = config.sample_rate ?? 100
|
|
configForm.all_groups = config.all_groups
|
|
configForm.group_ids = Array.isArray(config.group_ids) ? [...config.group_ids] : []
|
|
configForm.record_non_hits = config.record_non_hits
|
|
configForm.worker_count = config.worker_count || 4
|
|
configForm.queue_size = config.queue_size || 32768
|
|
configForm.block_status = config.block_status || 403
|
|
configForm.block_message = config.block_message || '内容审计命中风险规则,请调整输入后重试'
|
|
configForm.email_on_hit = config.email_on_hit ?? true
|
|
configForm.auto_ban_enabled = config.auto_ban_enabled ?? true
|
|
configForm.ban_threshold = config.ban_threshold || 10
|
|
configForm.violation_window_hours = config.violation_window_hours || 720
|
|
configForm.hit_retention_days = config.hit_retention_days || 180
|
|
configForm.non_hit_retention_days = Math.min(Math.max(config.non_hit_retention_days || 3, 1), 3)
|
|
configForm.pre_hash_check_enabled = config.pre_hash_check_enabled ?? false
|
|
}
|
|
|
|
async function loadAll() {
|
|
loading.value = true
|
|
try {
|
|
const [config, groupItems, runtimeStatus] = await Promise.all([
|
|
adminAPI.riskControl.getConfig(),
|
|
adminAPI.groups.getAll(),
|
|
adminAPI.riskControl.getStatus(),
|
|
])
|
|
applyConfig(config)
|
|
groups.value = groupItems
|
|
status.value = runtimeStatus
|
|
if (Array.isArray(runtimeStatus.api_key_statuses)) {
|
|
configForm.api_key_statuses = [...runtimeStatus.api_key_statuses]
|
|
prunePendingDeleteAPIKeyHashes()
|
|
}
|
|
await loadLogs()
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.riskControl.loadFailed')))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadStatus(silent = true) {
|
|
statusLoading.value = true
|
|
try {
|
|
const runtimeStatus = await adminAPI.riskControl.getStatus()
|
|
status.value = runtimeStatus
|
|
if (Array.isArray(runtimeStatus.api_key_statuses)) {
|
|
configForm.api_key_statuses = [...runtimeStatus.api_key_statuses]
|
|
prunePendingDeleteAPIKeyHashes()
|
|
}
|
|
} catch (err: unknown) {
|
|
if (!silent) {
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.riskControl.statusFailed')))
|
|
}
|
|
} finally {
|
|
statusLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function saveConfig() {
|
|
saving.value = true
|
|
try {
|
|
const payload: UpdateContentModerationConfig = {
|
|
enabled: configForm.enabled,
|
|
mode: configForm.mode,
|
|
base_url: configForm.base_url,
|
|
model: configForm.model,
|
|
timeout_ms: Number(configForm.timeout_ms) || 3000,
|
|
retry_count: Number(configForm.retry_count) || 0,
|
|
sample_rate: Number(configForm.sample_rate) || 0,
|
|
all_groups: configForm.all_groups,
|
|
group_ids: configForm.all_groups ? [] : [...configForm.group_ids],
|
|
record_non_hits: configForm.record_non_hits,
|
|
clear_api_key: configForm.clear_api_key,
|
|
worker_count: Number(configForm.worker_count) || 4,
|
|
queue_size: Number(configForm.queue_size) || 32768,
|
|
block_status: Number(configForm.block_status) || 403,
|
|
block_message: configForm.block_message || '内容审计命中风险规则,请调整输入后重试',
|
|
email_on_hit: configForm.email_on_hit,
|
|
auto_ban_enabled: configForm.auto_ban_enabled,
|
|
ban_threshold: Number(configForm.ban_threshold) || 10,
|
|
violation_window_hours: Number(configForm.violation_window_hours) || 720,
|
|
hit_retention_days: Number(configForm.hit_retention_days) || 180,
|
|
non_hit_retention_days: Math.min(Math.max(Number(configForm.non_hit_retention_days) || 3, 1), 3),
|
|
pre_hash_check_enabled: configForm.pre_hash_check_enabled,
|
|
}
|
|
const keys = parseApiKeys(configForm.api_keys_text)
|
|
if (!payload.clear_api_key && configForm.api_keys_mode === 'replace' && keys.length === 0) {
|
|
appStore.showError(t('admin.riskControl.apiKeysReplaceNoInput'))
|
|
return
|
|
}
|
|
if (keys.length > 0) {
|
|
payload.api_keys = keys
|
|
payload.api_keys_mode = configForm.api_keys_mode
|
|
payload.clear_api_key = false
|
|
}
|
|
if (!payload.clear_api_key && configForm.api_keys_mode !== 'replace' && pendingDeleteApiKeyHashes.value.length > 0) {
|
|
payload.delete_api_key_hashes = [...pendingDeleteApiKeyHashes.value]
|
|
}
|
|
|
|
const updated = await adminAPI.riskControl.updateConfig(payload)
|
|
applyConfig(updated)
|
|
settingsOpen.value = false
|
|
appStore.showSuccess(t('admin.riskControl.saved'))
|
|
await Promise.all([loadStatus(true), loadLogs()])
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.riskControl.saveFailed')))
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function loadLogs() {
|
|
logsLoading.value = true
|
|
try {
|
|
const params = {
|
|
page: pagination.page,
|
|
page_size: pagination.page_size,
|
|
result: filters.result || undefined,
|
|
group_id: filters.group_id || undefined,
|
|
endpoint: filters.endpoint || undefined,
|
|
search: filters.search || undefined,
|
|
from: normalizeDateTimeLocal(filters.from),
|
|
to: normalizeDateTimeLocal(filters.to),
|
|
}
|
|
const result = await adminAPI.riskControl.listLogs(params)
|
|
logs.value = result.items
|
|
pagination.total = result.total
|
|
pagination.page = result.page
|
|
pagination.page_size = result.page_size
|
|
pagination.pages = result.pages
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.riskControl.logsFailed')))
|
|
} finally {
|
|
logsLoading.value = false
|
|
}
|
|
}
|
|
|
|
function canUnbanRow(row: ContentModerationLog): boolean {
|
|
return Boolean(row.auto_banned && row.user_id && row.user_status === 'disabled')
|
|
}
|
|
|
|
function inputSummaryText(row: ContentModerationLog): string {
|
|
return row.input_excerpt || row.error || '-'
|
|
}
|
|
|
|
function openInputDetail(row: ContentModerationLog) {
|
|
inputDetailRow.value = row
|
|
}
|
|
|
|
function closeInputDetail() {
|
|
inputDetailRow.value = null
|
|
}
|
|
|
|
async function unbanUser(row: ContentModerationLog) {
|
|
if (!row.user_id || unbanningUserID.value !== null) return
|
|
unbanningUserID.value = row.user_id
|
|
try {
|
|
const result = await adminAPI.riskControl.unbanUser(row.user_id)
|
|
logs.value = logs.value.map((item) => {
|
|
if (item.user_id !== row.user_id) return item
|
|
return { ...item, user_status: result.status }
|
|
})
|
|
appStore.showSuccess(t('admin.riskControl.unbanSuccess'))
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.riskControl.unbanFailed')))
|
|
} finally {
|
|
unbanningUserID.value = null
|
|
}
|
|
}
|
|
|
|
async function deleteFlaggedHash() {
|
|
if (!isFlaggedHashInputValid.value || hashActionLoading.value) return
|
|
hashActionLoading.value = true
|
|
try {
|
|
const result = await adminAPI.riskControl.deleteFlaggedHash(flaggedHashInput.value)
|
|
flaggedHashInput.value = ''
|
|
await loadStatus(true)
|
|
appStore.showSuccess(result.deleted ? t('admin.riskControl.flaggedHashDeleted') : t('admin.riskControl.flaggedHashNotFound'))
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.riskControl.flaggedHashDeleteFailed')))
|
|
} finally {
|
|
hashActionLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function clearFlaggedHashes() {
|
|
if (hashActionLoading.value) return
|
|
const confirmed = window.confirm(t('admin.riskControl.clearFlaggedHashesConfirm'))
|
|
if (!confirmed) return
|
|
hashActionLoading.value = true
|
|
try {
|
|
const result = await adminAPI.riskControl.clearFlaggedHashes()
|
|
await loadStatus(true)
|
|
appStore.showSuccess(t('admin.riskControl.flaggedHashesCleared', { count: result.deleted }))
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.riskControl.flaggedHashesClearFailed')))
|
|
} finally {
|
|
hashActionLoading.value = false
|
|
}
|
|
}
|
|
|
|
function openSettings() {
|
|
activeSettingsTab.value = 'basic'
|
|
settingsOpen.value = true
|
|
}
|
|
|
|
function reloadLogsFromFirstPage() {
|
|
pagination.page = 1
|
|
void loadLogs()
|
|
}
|
|
|
|
function onPageChange(page: number) {
|
|
pagination.page = page
|
|
void loadLogs()
|
|
}
|
|
|
|
function onPageSizeChange(pageSize: number) {
|
|
pagination.page = 1
|
|
pagination.page_size = pageSize
|
|
void loadLogs()
|
|
}
|
|
|
|
function toggleClearApiKey() {
|
|
configForm.clear_api_key = !configForm.clear_api_key
|
|
if (configForm.clear_api_key) {
|
|
configForm.api_keys_text = ''
|
|
configForm.api_keys_mode = 'append'
|
|
testedApiKeyStatuses.value = []
|
|
pendingDeleteApiKeyHashes.value = []
|
|
}
|
|
}
|
|
|
|
function setAPIKeysMode(mode: APIKeysWriteMode) {
|
|
configForm.api_keys_mode = mode
|
|
if (mode === 'replace') {
|
|
pendingDeleteApiKeyHashes.value = []
|
|
}
|
|
}
|
|
|
|
async function testApiKeys(useInputKeys: boolean) {
|
|
const keys = useInputKeys ? parseApiKeys(configForm.api_keys_text) : []
|
|
if (useInputKeys && keys.length === 0) {
|
|
appStore.showError(t('admin.riskControl.apiKeyTestNoInput'))
|
|
return
|
|
}
|
|
apiKeyTesting.value = true
|
|
try {
|
|
const result = await adminAPI.riskControl.testAPIKeys({
|
|
api_keys: keys,
|
|
base_url: configForm.base_url,
|
|
model: configForm.model,
|
|
timeout_ms: Number(configForm.timeout_ms) || 3000,
|
|
prompt: moderationTestPrompt.value,
|
|
images: moderationTestImages.value,
|
|
})
|
|
moderationTestResult.value = result.audit_result ?? null
|
|
if (useInputKeys) {
|
|
testedApiKeyStatuses.value = result.items.map((item) => ({ ...item, configured: false }))
|
|
} else {
|
|
mergeConfiguredAPIKeyStatuses(result.items)
|
|
testedApiKeyStatuses.value = []
|
|
await loadStatus(true)
|
|
}
|
|
appStore.showSuccess(t('admin.riskControl.apiKeyTestDone', { count: result.items.length }))
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.riskControl.apiKeyTestFailed')))
|
|
} finally {
|
|
apiKeyTesting.value = false
|
|
}
|
|
}
|
|
|
|
function mergeConfiguredAPIKeyStatuses(items: ContentModerationAPIKeyStatus[]) {
|
|
if (!hasModerationAuditInput.value || configForm.api_key_statuses.length === 0) {
|
|
configForm.api_key_statuses = items
|
|
return
|
|
}
|
|
const updates = new Map(items.map((item) => [item.key_hash, item]))
|
|
configForm.api_key_statuses = configForm.api_key_statuses.map((item) => updates.get(item.key_hash) ?? item)
|
|
}
|
|
|
|
function toggleDeleteStoredApiKey(row: ContentModerationAPIKeyStatus) {
|
|
if (!row.configured || !row.key_hash) return
|
|
const index = pendingDeleteApiKeyHashes.value.indexOf(row.key_hash)
|
|
if (index >= 0) {
|
|
pendingDeleteApiKeyHashes.value.splice(index, 1)
|
|
return
|
|
}
|
|
pendingDeleteApiKeyHashes.value.push(row.key_hash)
|
|
}
|
|
|
|
function isStoredApiKeyPendingDelete(row: ContentModerationAPIKeyStatus): boolean {
|
|
return row.configured && row.key_hash !== '' && pendingDeleteApiKeyHashes.value.includes(row.key_hash)
|
|
}
|
|
|
|
function prunePendingDeleteAPIKeyHashes() {
|
|
const currentHashes = new Set(savedApiKeyRows.value.map((row) => row.key_hash).filter(Boolean))
|
|
pendingDeleteApiKeyHashes.value = pendingDeleteApiKeyHashes.value.filter((hash) => currentHashes.has(hash))
|
|
}
|
|
|
|
function clearModerationTestInput() {
|
|
moderationTestPrompt.value = ''
|
|
moderationTestImages.value = []
|
|
moderationTestResult.value = null
|
|
}
|
|
|
|
function removeModerationTestImage(index: number) {
|
|
moderationTestImages.value.splice(index, 1)
|
|
}
|
|
|
|
async function handleModerationImageUpload(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
await addModerationTestFiles(input.files)
|
|
input.value = ''
|
|
}
|
|
|
|
async function handleModerationImageDrop(event: DragEvent) {
|
|
await addModerationTestFiles(event.dataTransfer?.files ?? null)
|
|
}
|
|
|
|
async function handleModerationImagePaste(event: ClipboardEvent) {
|
|
const files = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith('image/'))
|
|
if (files.length === 0) return
|
|
event.preventDefault()
|
|
await addModerationTestFiles(files)
|
|
}
|
|
|
|
async function addModerationTestFiles(files: FileList | File[] | null) {
|
|
if (!files) return
|
|
const items = Array.from(files).filter((file) => file.type.startsWith('image/'))
|
|
for (const file of items) {
|
|
if (moderationTestImages.value.length >= maxModerationTestImages) {
|
|
appStore.showError(t('admin.riskControl.auditTestImageLimit', { count: maxModerationTestImages }))
|
|
return
|
|
}
|
|
if (file.size > maxModerationTestImageSize) {
|
|
appStore.showError(t('admin.riskControl.auditTestImageTooLarge'))
|
|
continue
|
|
}
|
|
try {
|
|
moderationTestImages.value.push(await fileToDataURL(file))
|
|
} catch {
|
|
appStore.showError(t('admin.riskControl.auditTestImageReadFailed'))
|
|
}
|
|
}
|
|
}
|
|
|
|
function fileToDataURL(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.onload = () => resolve(String(reader.result || ''))
|
|
reader.onerror = () => reject(reader.error)
|
|
reader.readAsDataURL(file)
|
|
})
|
|
}
|
|
|
|
function toggleGroup(groupID: number) {
|
|
const index = configForm.group_ids.indexOf(groupID)
|
|
if (index >= 0) {
|
|
configForm.group_ids.splice(index, 1)
|
|
} else {
|
|
configForm.group_ids.push(groupID)
|
|
}
|
|
}
|
|
|
|
function isGroupSelected(groupID: number): boolean {
|
|
return configForm.group_ids.includes(groupID)
|
|
}
|
|
|
|
function modeLabel(mode: ModerationMode): string {
|
|
const found = modeOptions.value.find((option) => option.value === mode)
|
|
return found?.label ?? mode
|
|
}
|
|
|
|
function modeDescription(mode: ModerationMode): string {
|
|
const descriptions: Record<ModerationMode, string> = {
|
|
pre_block: t('admin.riskControl.modePreBlockDesc'),
|
|
observe: t('admin.riskControl.modeObserveDesc'),
|
|
off: t('admin.riskControl.modeOffDesc'),
|
|
}
|
|
return descriptions[mode] ?? ''
|
|
}
|
|
|
|
function resultLabel(row: ContentModerationLog): string {
|
|
if (row.action === 'block') return t('admin.riskControl.action.block')
|
|
if (row.action === 'error' || row.error) return t('admin.riskControl.action.error')
|
|
if (row.flagged) return t('admin.riskControl.result.hit')
|
|
return t('admin.riskControl.result.pass')
|
|
}
|
|
|
|
function resultBadgeClass(row: ContentModerationLog): string {
|
|
if (row.action === 'block') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
|
if (row.action === 'error' || row.error) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
|
if (row.flagged) return 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300'
|
|
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
|
}
|
|
|
|
function workerSlotClass(state: WorkerSlotState): string {
|
|
if (state === 'active') {
|
|
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-900/20 dark:text-sky-300'
|
|
}
|
|
if (state === 'idle') {
|
|
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-900/20 dark:text-emerald-300'
|
|
}
|
|
return 'border-gray-100 bg-white text-gray-400 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-500'
|
|
}
|
|
|
|
function workerDotClass(state: WorkerSlotState): string {
|
|
if (state === 'active') return 'bg-sky-500'
|
|
if (state === 'idle') return 'bg-emerald-500'
|
|
return 'bg-gray-300 dark:bg-dark-500'
|
|
}
|
|
|
|
function percent(value: number): string {
|
|
if (!Number.isFinite(value)) return '-'
|
|
return `${(value * 100).toFixed(1)}%`
|
|
}
|
|
|
|
function percentWidth(value: number): string {
|
|
if (!Number.isFinite(value)) return '0%'
|
|
return `${Math.min(100, Math.max(0, value * 100)).toFixed(1)}%`
|
|
}
|
|
|
|
function latencyText(value: number | null): string {
|
|
if (value === null || value === undefined) return '-'
|
|
return `${value} ms`
|
|
}
|
|
|
|
function apiKeyRowKey(row: ContentModerationAPIKeyStatus, index: number): string {
|
|
return `${row.configured ? 'saved' : 'test'}-${row.key_hash || index}`
|
|
}
|
|
|
|
function apiKeyStatusLabel(statusValue: ContentModerationAPIKeyStatus['status']): string {
|
|
const labels: Record<ContentModerationAPIKeyStatus['status'], string> = {
|
|
ok: t('admin.riskControl.apiKeyStatusOk'),
|
|
error: t('admin.riskControl.apiKeyStatusError'),
|
|
frozen: t('admin.riskControl.apiKeyStatusFrozen'),
|
|
unknown: t('admin.riskControl.apiKeyStatusUnknown'),
|
|
}
|
|
return labels[statusValue] ?? labels.unknown
|
|
}
|
|
|
|
function apiKeyStatusBadgeClass(statusValue: ContentModerationAPIKeyStatus['status']): string {
|
|
const classes: Record<ContentModerationAPIKeyStatus['status'], string> = {
|
|
ok: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300',
|
|
error: 'bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300',
|
|
frozen: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300',
|
|
unknown: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300',
|
|
}
|
|
return classes[statusValue] ?? classes.unknown
|
|
}
|
|
|
|
function apiKeyStatusDotClass(statusValue: ContentModerationAPIKeyStatus['status']): string {
|
|
const classes: Record<ContentModerationAPIKeyStatus['status'], string> = {
|
|
ok: 'bg-emerald-500',
|
|
error: 'bg-amber-500',
|
|
frozen: 'bg-red-500',
|
|
unknown: 'bg-gray-400',
|
|
}
|
|
return classes[statusValue] ?? classes.unknown
|
|
}
|
|
|
|
function apiKeyStatusMeta(row: ContentModerationAPIKeyStatus): string {
|
|
const parts: string[] = []
|
|
parts.push(t('admin.riskControl.apiKeyFailureCount', { count: row.failure_count || 0 }))
|
|
if (row.last_latency_ms > 0) {
|
|
parts.push(t('admin.riskControl.apiKeyLatency', { ms: row.last_latency_ms }))
|
|
}
|
|
if (row.last_http_status > 0) {
|
|
parts.push(t('admin.riskControl.apiKeyHTTPStatus', { status: row.last_http_status }))
|
|
}
|
|
if (row.frozen_until) {
|
|
parts.push(t('admin.riskControl.apiKeyFrozenUntil', { time: formatDateTime(row.frozen_until) }))
|
|
} else if (row.last_checked_at) {
|
|
parts.push(t('admin.riskControl.apiKeyLastChecked', { time: formatDateTime(row.last_checked_at) }))
|
|
} else {
|
|
parts.push(t('admin.riskControl.apiKeyNotTested'))
|
|
}
|
|
return parts.join(' / ')
|
|
}
|
|
|
|
function parseApiKeys(value: string): string[] {
|
|
return value
|
|
.split(/\r?\n/)
|
|
.map((item) => item.trim())
|
|
.filter((item, index, arr) => item && arr.indexOf(item) === index)
|
|
}
|
|
|
|
function violationCountText(row: ContentModerationLog): string {
|
|
if (!row.flagged) return '-'
|
|
return t('admin.riskControl.violationCount', { count: row.violation_count || 1 })
|
|
}
|
|
|
|
function normalizeDateTimeLocal(value: string): string | undefined {
|
|
if (!value) return undefined
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return undefined
|
|
return date.toISOString()
|
|
}
|
|
|
|
function formatDateTime(value: string): string {
|
|
return formatDateTimeValue(value) || '-'
|
|
}
|
|
|
|
function formatNumber(value: number): string {
|
|
return new Intl.NumberFormat().format(value)
|
|
}
|
|
|
|
onMounted(() => {
|
|
void loadAll()
|
|
statusTimer = window.setInterval(() => {
|
|
void loadStatus(true)
|
|
}, 15000)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (statusTimer !== null) {
|
|
window.clearInterval(statusTimer)
|
|
statusTimer = null
|
|
}
|
|
})
|
|
</script>
|