feat: add i18n for Risk Control pages
Some checks failed
CI / test (push) Failing after 4s
CI / golangci-lint (push) Failing after 3s
Security Scan / backend-security (push) Failing after 3s
Security Scan / frontend-security (push) Failing after 3s

- Add nav.risk key (en: Risk Control, zh: 风控中心)
- Add admin.risk.* keys for all UI text in view and components
- Replace all hardcoded English strings with t() calls
This commit is contained in:
win 2026-03-28 10:40:40 +08:00
parent 52ad76e6a4
commit 3333307ec1
7 changed files with 152 additions and 40 deletions

View File

@ -17,7 +17,7 @@
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Account Risk Detail</h2>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.risk.drawer.title') }}</h2>
<button
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
@click="$emit('close')"
@ -29,28 +29,28 @@
</div>
<template v-if="riskStore.loading && !detail">
<div class="text-sm text-gray-500 dark:text-gray-400">Loading</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.risk.loading') }}</div>
</template>
<template v-else-if="detail">
<!-- Basic info -->
<div class="space-y-2 mb-6">
<div class="flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">Email</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.risk.drawer.email') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ detail.email }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">Platform</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.risk.drawer.platform') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ detail.platform }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">Risk Level</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.risk.drawer.riskLevel') }}</span>
<span :class="levelClass(detail.risk_level)" class="font-semibold capitalize">
{{ detail.risk_level }}
</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-500 dark:text-gray-400">Risk Score</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.risk.drawer.riskScore') }}</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ (detail.risk_score * 100).toFixed(1) }}%
</span>
@ -67,7 +67,7 @@
<!-- Override form -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mb-6">
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Override Risk Level</h3>
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">{{ t('admin.risk.drawer.overrideTitle') }}</h3>
<div class="space-y-2">
<select
v-model="overrideLevel"
@ -80,7 +80,7 @@
<input
v-model="overrideReason"
type="text"
placeholder="Reason (required)"
:placeholder="t('admin.risk.drawer.overrideReason')"
class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-1.5"
/>
<button
@ -88,14 +88,14 @@
class="w-full rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
@click="onOverride"
>
{{ overriding ? 'Saving…' : 'Apply Override' }}
{{ overriding ? t('admin.risk.drawer.applying') : t('admin.risk.drawer.applyOverride') }}
</button>
</div>
</div>
<!-- 24h behavior trend -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">24h Behavior</h3>
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">{{ t('admin.risk.drawer.behavior24h') }}</h3>
<div class="h-20">
<RiskTrendChart :hours="detail.behavior_24h ?? []" />
</div>
@ -108,6 +108,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRiskStore } from '@/stores/risk'
import RiskTrendChart from './RiskTrendChart.vue'
@ -122,6 +123,7 @@ const emit = defineEmits<{
}>()
const riskStore = useRiskStore()
const { t } = useI18n()
const detail = ref(riskStore.accountDetail)
const overrideLevel = ref('low')
const overrideReason = ref('')

View File

@ -1,25 +1,25 @@
<template>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Monitored</p>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('admin.risk.summaryCards.totalMonitored') }}</p>
<p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
{{ summary?.total_monitored ?? '—' }}
</p>
</div>
<div class="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide">High Risk</p>
<p class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide">{{ t('admin.risk.summaryCards.highRisk') }}</p>
<p class="mt-1 text-2xl font-semibold text-red-700 dark:text-red-300">
{{ summary?.high_risk_count ?? '—' }}
</p>
</div>
<div class="rounded-lg border border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20 p-4">
<p class="text-xs text-yellow-600 dark:text-yellow-400 uppercase tracking-wide">Medium Risk</p>
<p class="text-xs text-yellow-600 dark:text-yellow-400 uppercase tracking-wide">{{ t('admin.risk.summaryCards.mediumRisk') }}</p>
<p class="mt-1 text-2xl font-semibold text-yellow-700 dark:text-yellow-300">
{{ summary?.medium_risk_count ?? '—' }}
</p>
</div>
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Blocked</p>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('admin.risk.summaryCards.blocked') }}</p>
<p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
{{ summary?.blocked_count ?? '—' }}
</p>
@ -28,9 +28,11 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { RiskSummary } from '@/api/admin/risk'
defineProps<{
summary: RiskSummary | null
}>()
const { t } = useI18n()
</script>

View File

@ -1,21 +1,21 @@
<template>
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Risk System Settings</h3>
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">{{ t('admin.risk.settings.title') }}</h3>
<form class="space-y-3" @submit.prevent="onSave">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Phase</label>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('admin.risk.settings.phase') }}</label>
<select
v-model="form.phase"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="off">Off</option>
<option value="observe">Observe</option>
<option value="enforce">Enforce</option>
<option value="observe">{{ t('admin.risk.settings.phaseObserve') }}</option>
<option value="enforce">{{ t('admin.risk.settings.phaseEnforce') }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
Medium Threshold ({{ form.medium_threshold }})
{{ t('admin.risk.settings.mediumThreshold', { v: form.medium_threshold }) }}
</label>
<input
v-model.number="form.medium_threshold"
@ -28,7 +28,7 @@
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
High Threshold ({{ form.high_threshold }})
{{ t('admin.risk.settings.highThreshold', { v: form.high_threshold }) }}
</label>
<input
v-model.number="form.high_threshold"
@ -46,22 +46,23 @@
type="checkbox"
class="rounded border-gray-300 text-blue-600"
/>
<label for="risk-enabled" class="text-xs text-gray-600 dark:text-gray-400">Enabled</label>
<label for="risk-enabled" class="text-xs text-gray-600 dark:text-gray-400">{{ t('admin.risk.settings.enabled') }}</label>
</div>
<button
type="submit"
:disabled="saving"
class="w-full rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{{ saving ? 'Saving…' : 'Save Settings' }}
{{ saving ? t('admin.risk.settings.saving') : t('admin.risk.settings.save') }}
</button>
<p v-if="saved" class="text-xs text-green-600 dark:text-green-400 text-center">Saved</p>
<p v-if="saved" class="text-xs text-green-600 dark:text-green-400 text-center">{{ t('admin.risk.settings.saved') }}</p>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRiskStore } from '@/stores/risk'
import type { RiskSettings } from '@/api/admin/risk'
@ -70,6 +71,7 @@ const props = defineProps<{
}>()
const riskStore = useRiskStore()
const { t } = useI18n()
const saving = ref(false)
const saved = ref(false)

View File

@ -591,7 +591,7 @@ const adminNavItems = computed((): NavItem[] => {
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
{ path: '/admin/risk', label: 'Risk Control', icon: ShieldExclamationIcon }
{ path: '/admin/risk', label: t('nav.risk'), icon: ShieldExclamationIcon }
]
// API

View File

@ -352,7 +352,8 @@ export default {
mySubscriptions: 'My Subscriptions',
buySubscription: 'Recharge / Subscription',
docs: 'Docs',
sora: 'Sora Studio'
sora: 'Sora Studio',
risk: 'Risk Control'
},
// Auth
@ -4630,6 +4631,57 @@ export default {
loadFailed: 'Failed to load profiles',
saveFailed: 'Failed to save profile',
deleteFailed: 'Failed to delete profile'
},
// Risk Control
risk: {
title: 'Risk Control',
allLevels: 'All Levels',
allPlatforms: 'All Platforms',
refresh: 'Refresh',
email: 'Email',
platform: 'Platform',
level: 'Level',
score: 'Score',
detail: 'Detail',
loading: 'Loading…',
noAccounts: 'No accounts found',
total: '{n} total',
prev: 'Prev',
next: 'Next',
overridden: '(overridden)',
riskDistribution: 'Risk Distribution',
summaryCards: {
totalMonitored: 'Total Monitored',
highRisk: 'High Risk',
mediumRisk: 'Medium Risk',
blocked: 'Blocked'
},
settings: {
title: 'Risk System Settings',
phase: 'Phase',
phaseOff: 'Off',
phaseObserve: 'Observe',
phaseEnforce: 'Enforce',
mediumThreshold: 'Medium Threshold ({v})',
highThreshold: 'High Threshold ({v})',
enabled: 'Enabled',
save: 'Save Settings',
saving: 'Saving…',
saved: 'Saved'
},
drawer: {
title: 'Account Risk Detail',
email: 'Email',
platform: 'Platform',
riskLevel: 'Risk Level',
riskScore: 'Risk Score',
overrideTitle: 'Override Risk Level',
overrideReason: 'Reason (required)',
applyOverride: 'Apply Override',
applying: 'Saving…',
behavior24h: '24h Behavior'
}
}
},

View File

@ -352,7 +352,8 @@ export default {
mySubscriptions: '我的订阅',
buySubscription: '充值/订阅',
docs: '文档',
sora: 'Sora 创作'
sora: 'Sora 创作',
risk: '风控中心'
},
// Auth
@ -4794,6 +4795,57 @@ export default {
loadFailed: '加载模板失败',
saveFailed: '保存模板失败',
deleteFailed: '删除模板失败'
},
// 风控中心
risk: {
title: '风控中心',
allLevels: '全部风险等级',
allPlatforms: '全部平台',
refresh: '刷新',
email: '邮箱',
platform: '平台',
level: '风险等级',
score: '风险分',
detail: '详情',
loading: '加载中…',
noAccounts: '暂无账号',
total: '共 {n} 条',
prev: '上一页',
next: '下一页',
overridden: '(已覆盖)',
riskDistribution: '风险分布',
summaryCards: {
totalMonitored: '监控总数',
highRisk: '高风险',
mediumRisk: '中风险',
blocked: '已封禁'
},
settings: {
title: '风控系统设置',
phase: '运行阶段',
phaseOff: '关闭',
phaseObserve: '观察',
phaseEnforce: '执法',
mediumThreshold: '中风险阈值({v}',
highThreshold: '高风险阈值({v}',
enabled: '启用',
save: '保存设置',
saving: '保存中…',
saved: '已保存'
},
drawer: {
title: '账号风控详情',
email: '邮箱',
platform: '平台',
riskLevel: '风险等级',
riskScore: '风险分',
overrideTitle: '覆盖风险等级',
overrideReason: '原因(必填)',
applyOverride: '应用覆盖',
applying: '保存中…',
behavior24h: '24小时行为'
}
}
},

View File

@ -14,7 +14,7 @@
class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-1.5"
@change="applyFilter"
>
<option value="">All Levels</option>
<option value="">{{ t('admin.risk.allLevels') }}</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
@ -24,7 +24,7 @@
class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white px-3 py-1.5"
@change="applyFilter"
>
<option value="">All Platforms</option>
<option value="">{{ t('admin.risk.allPlatforms') }}</option>
<option value="claude">Claude</option>
<option value="openai">OpenAI</option>
<option value="gemini">Gemini</option>
@ -34,7 +34,7 @@
class="ml-auto rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
@click="applyFilter"
>
Refresh
{{ t('admin.risk.refresh') }}
</button>
</div>
@ -43,10 +43,10 @@
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Email</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Platform</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Level</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Score</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('admin.risk.email') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('admin.risk.platform') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('admin.risk.level') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ t('admin.risk.score') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"></th>
</tr>
</thead>
@ -59,7 +59,7 @@
>
<td class="px-4 py-3 text-gray-900 dark:text-white truncate max-w-[200px]">
{{ item.email }}
<span v-if="item.is_overridden" class="ml-1 text-xs text-blue-500">(overridden)</span>
<span v-if="item.is_overridden" class="ml-1 text-xs text-blue-500">{{ t('admin.risk.overridden') }}</span>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 capitalize">{{ item.platform }}</td>
<td class="px-4 py-3">
@ -71,12 +71,12 @@
{{ (item.risk_score * 100).toFixed(1) }}%
</td>
<td class="px-4 py-3 text-right">
<button class="text-blue-600 hover:text-blue-700 text-xs">Detail </button>
<button class="text-blue-600 hover:text-blue-700 text-xs">{{ t('admin.risk.detail') }} </button>
</td>
</tr>
<tr v-if="!riskStore.accounts?.items?.length">
<td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500 text-sm">
{{ riskStore.loading ? 'Loading…' : 'No accounts found' }}
{{ riskStore.loading ? t('admin.risk.loading') : t('admin.risk.noAccounts') }}
</td>
</tr>
</tbody>
@ -88,7 +88,7 @@
class="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between"
>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ riskStore.accounts?.total ?? 0 }} total
{{ t('admin.risk.total', { n: riskStore.accounts?.total ?? 0 }) }}
</span>
<div class="flex gap-2">
<button
@ -96,14 +96,14 @@
class="rounded border border-gray-300 dark:border-gray-600 px-2 py-1 text-xs disabled:opacity-40"
@click="changePage(filter.page - 1)"
>
Prev
{{ t('admin.risk.prev') }}
</button>
<button
:disabled="(filter.page * filter.limit) >= (riskStore.accounts?.total ?? 0)"
class="rounded border border-gray-300 dark:border-gray-600 px-2 py-1 text-xs disabled:opacity-40"
@click="changePage(filter.page + 1)"
>
Next
{{ t('admin.risk.next') }}
</button>
</div>
</div>
@ -115,7 +115,7 @@
<RiskSystemStatusCard :settings="riskStore.settings" />
<div class="card p-4">
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Risk Distribution</h3>
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-3">{{ t('admin.risk.riskDistribution') }}</h3>
<RiskDistributionChart :summary="riskStore.summary" />
</div>
</div>
@ -134,6 +134,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRiskStore } from '@/stores/risk'
import AppLayout from '@/components/layout/AppLayout.vue'
import RiskSummaryCards from '@/components/admin/risk/RiskSummaryCards.vue'
@ -142,6 +143,7 @@ import RiskSystemStatusCard from '@/components/admin/risk/RiskSystemStatusCard.v
import RiskAccountDrawer from '@/components/admin/risk/RiskAccountDrawer.vue'
const riskStore = useRiskStore()
const { t } = useI18n()
const drawerOpen = ref(false)
const selectedAccountId = ref<number | null>(null)