Redesign sensitive info protection settings

This commit is contained in:
fawney19
2026-05-14 11:14:20 +08:00
parent 91955ad1e0
commit 509bd30252
71 changed files with 3254 additions and 884 deletions

View File

@@ -1206,7 +1206,8 @@ function editApiKey(apiKey: AdminApiKey) {
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
allowed_providers: apiKey.allowed_providers == null ? null : [...apiKey.allowed_providers],
allowed_api_formats: apiKey.allowed_api_formats == null ? null : [...apiKey.allowed_api_formats],
allowed_models: apiKey.allowed_models == null ? null : [...apiKey.allowed_models]
allowed_models: apiKey.allowed_models == null ? null : [...apiKey.allowed_models],
feature_settings: apiKey.feature_settings ?? null
}
showKeyFormDialog.value = true
@@ -1418,7 +1419,8 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models
allowed_models: data.allowed_models,
feature_settings: data.feature_settings ?? null
}
const { message: _, ...updated } = await adminApi.updateApiKey(data.id, updateData)
// 局部更新:合并字段,避免覆盖丢失列表已有信息
@@ -1448,7 +1450,8 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models
allowed_models: data.allowed_models,
feature_settings: data.feature_settings ?? null
}
const response = await adminApi.createStandaloneApiKey(createData)
newKeyValue.value = response.key

View File

@@ -134,8 +134,8 @@
</p>
<p
v-if="module.name === 'chat_pii_redaction'"
class="mt-3 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
v-if="module.name === 'chat_pii_redaction' && getModuleStatusCopy(module)"
class="mt-3 text-xs text-muted-foreground"
>
{{ getModuleStatusCopy(module) }}
</p>
@@ -257,9 +257,9 @@ function getModuleStatusCopy(module: { name: string; enabled: boolean; active: b
if (module.name !== 'chat_pii_redaction') {
return module.enabled ? '已启用' : '已禁用'
}
if (!module.config_validated) return '配置异常,替换保护未生效'
if (!module.enabled) return '全局替换开关未开启,所有供应商均不会执行替换保护'
return '全局替换开关已开启,可在供应商中单独启用'
if (!module.config_validated) return '配置异常'
if (!module.enabled) return ''
return '已开启'
}
// 所有模块列表(按 admin_menu_order 排序)

View File

@@ -1043,6 +1043,20 @@
{{ editingUserApiKey ? '留空表示保持当前值,填 0 表示不限并发' : '留空表示不限并发,填 0 也表示不限并发' }}
</p>
</div>
<div class="rounded-lg border border-border bg-muted/30 p-3 space-y-3">
<div class="flex items-center justify-between gap-3">
<Label class="text-sm font-medium">敏感信息保护</Label>
<Switch v-model="userApiKeyForm.chat_pii_redaction_enabled" />
</div>
<div class="flex items-center justify-between gap-3">
<Label class="text-sm font-medium">占位符说明</Label>
<Switch
v-model="userApiKeyForm.chat_pii_redaction_placeholder_notice"
:disabled="!userApiKeyForm.chat_pii_redaction_enabled"
/>
</div>
</div>
</div>
<template #footer>
@@ -1256,7 +1270,8 @@ import {
AvatarFallback,
Pagination,
RefreshButton,
Checkbox
Checkbox,
Switch
} from '@/components/ui'
import {
@@ -1284,6 +1299,10 @@ import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
import { parseApiError } from '@/utils/errorParser'
import { formatTokens, formatRateLimitInheritable, formatRateLimitSimple, isRateLimitInherited, isRateLimitUnlimited } from '@/utils/format'
import { parseNumberInput } from '@/utils/form'
import {
mergeChatPiiRedactionFeatureSettings,
readChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
import { log } from '@/utils/logger'
import { useBatchSelection } from '@/composables/useBatchSelection'
@@ -1315,6 +1334,8 @@ const userApiKeyForm = ref({
name: '',
rate_limit: undefined as number | undefined,
concurrent_limit: undefined as number | undefined,
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
})
// 用户统计
@@ -1601,6 +1622,7 @@ function editUser(user: User) {
role: user.role,
is_active: user.is_active,
group_ids: (user.groups || []).map(group => group.id),
feature_settings: user.feature_settings ?? null,
}
showUserFormDialog.value = true
}
@@ -1621,6 +1643,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
unlimited: data.unlimited,
role: data.role,
group_ids: data.group_ids ?? [],
feature_settings: data.feature_settings ?? null,
}
if (data.password) {
updateData.password = data.password
@@ -1638,6 +1661,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string; un
unlimited: data.unlimited,
role: data.role,
group_ids: data.group_ids ?? [],
feature_settings: data.feature_settings ?? null,
})
// 如果创建时指定为禁用,则更新状态
if (data.is_active === false && newUser) {
@@ -1684,21 +1708,27 @@ async function loadUserApiKeys(userId: string) {
}
function openCreateUserApiKeyDialog() {
const redactionFeature = readChatPiiRedactionFeatureSettings(null)
userApiKeyForm.value = {
name: `Key-${new Date().toISOString().split('T')[0]}`,
rate_limit: undefined,
concurrent_limit: undefined,
chat_pii_redaction_enabled: redactionFeature.enabled,
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
}
editingUserApiKey.value = null
showUserApiKeyFormDialog.value = true
}
function openEditUserApiKeyDialog(apiKey: ApiKey) {
const redactionFeature = readChatPiiRedactionFeatureSettings(apiKey.feature_settings)
editingUserApiKey.value = apiKey
userApiKeyForm.value = {
name: apiKey.name || '',
rate_limit: apiKey.rate_limit ?? undefined,
concurrent_limit: apiKey.concurrent_limit ?? undefined,
chat_pii_redaction_enabled: redactionFeature.enabled,
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
}
showUserApiKeyFormDialog.value = true
}
@@ -1710,6 +1740,8 @@ function closeUserApiKeyFormDialog() {
name: '',
rate_limit: undefined,
concurrent_limit: undefined,
chat_pii_redaction_enabled: false,
chat_pii_redaction_placeholder_notice: true,
}
}
@@ -1727,6 +1759,10 @@ async function submitUserApiKeyForm() {
name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
concurrent_limit: userApiKeyForm.value.concurrent_limit,
feature_settings: mergeChatPiiRedactionFeatureSettings(editingUserApiKey.value.feature_settings, {
enabled: userApiKeyForm.value.chat_pii_redaction_enabled,
inject_model_instruction: userApiKeyForm.value.chat_pii_redaction_placeholder_notice,
}),
})
success('API Key已更新')
} else {
@@ -1734,6 +1770,10 @@ async function submitUserApiKeyForm() {
name: userApiKeyForm.value.name,
rate_limit: userApiKeyForm.value.rate_limit ?? 0,
concurrent_limit: userApiKeyForm.value.concurrent_limit,
feature_settings: mergeChatPiiRedactionFeatureSettings(null, {
enabled: userApiKeyForm.value.chat_pii_redaction_enabled,
inject_model_instruction: userApiKeyForm.value.chat_pii_redaction_placeholder_notice,
}),
})
newApiKey.value = response.key || ''
showNewApiKeyDialog.value = true

View File

@@ -1,8 +1,8 @@
<template>
<PageContainer>
<PageHeader
title="敏感信息替换保护"
description="对聊天消息中的手机号、邮箱、证件号、银行卡号、IP、API Key 与令牌进行可逆占位符替换,防止原文发送给上游供应商。"
title="敏感信息保护"
description="发送给供应商前将聊天消息中的敏感信息替换为占位符,返回客户端前自动还原。"
:icon="ShieldCheck"
>
<template #actions>
@@ -12,7 +12,7 @@
@click="loadConfig"
>
<RefreshCw
class="w-4 h-4 mr-2"
class="mr-2 h-4 w-4"
:class="{ 'animate-spin': loading }"
/>
刷新
@@ -29,27 +29,24 @@
<div class="mt-6 space-y-6">
<section class="rounded-2xl border border-border bg-card p-5">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-2">
<div class="flex items-center gap-2">
<span
class="h-2.5 w-2.5 rounded-full ring-2 ring-offset-2 ring-offset-background"
:class="redactionConfig.enabled ? 'bg-primary ring-primary/30' : 'bg-muted ring-muted/60'"
/>
<div class="space-y-1">
<div
v-if="statusLabel"
class="flex items-center gap-2"
>
<span class="h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-primary/30 ring-offset-2 ring-offset-background" />
<p class="text-sm font-semibold text-foreground">
{{ statusLabel }}
</p>
</div>
<p class="max-w-3xl text-sm text-muted-foreground">
发送给供应商前将聊天消息中的敏感信息替换为占位符返回客户端前自动还原
管理员只配置功能是否启用和匹配规则用户用户 Key独立余额 Key 可在各自配置中附加此功能
</p>
</div>
<div class="flex items-center gap-3 rounded-xl border border-border bg-muted/40 px-4 py-3">
<div class="text-right">
<p class="text-sm font-medium text-foreground">
启敏感信息替换保护
</p>
<p class="text-xs text-muted-foreground">
关闭后所有供应商均不会执行替换
敏感信息保护
</p>
</div>
<Switch
@@ -61,87 +58,133 @@
</section>
<CardSection
title="启用范围"
description="关闭后所有供应商都不会执行替换开启后按下方启用范围决定是全部供应商生效还是指定供应商生效"
title="替换类型配置"
description="统一配置所有可用规则"
>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<button
v-for="option in scopeOptions"
:key="option.value"
type="button"
class="rounded-xl border p-4 text-left transition-all duration-200"
:class="redactionConfig.provider_scope === option.value
? 'border-primary bg-primary/10 text-primary shadow-sm'
: 'border-border bg-card/70 text-muted-foreground hover:border-primary/50 hover:text-foreground'"
@click="redactionConfig.provider_scope = option.value"
>
<div class="flex items-center justify-between gap-3">
<span class="text-sm font-semibold">{{ option.label }}</span>
<span
class="h-2 w-2 rounded-full"
:class="redactionConfig.provider_scope === option.value ? 'bg-primary' : 'bg-muted'"
/>
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="text-sm text-muted-foreground">
规则按表格顺序保存。系统预置规则可直接修改,自定义规则可删除。
</div>
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">
{{ option.helper }}
</p>
</button>
<Button
variant="outline"
size="sm"
@click="addCustomRule"
>
<Plus class="mr-2 h-4 w-4" />
新增规则
</Button>
</div>
<div class="overflow-x-auto rounded-xl border border-border">
<table class="min-w-[920px] w-full text-sm">
<thead class="bg-muted/50 text-left text-xs font-medium text-muted-foreground">
<tr>
<th class="w-[220px] px-4 py-3">
规则名称
</th>
<th class="px-4 py-3">
正则
</th>
<th class="w-[120px] px-4 py-3">
是否启用
</th>
<th class="w-[150px] px-4 py-3 text-right">
操作
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(rule, index) in redactionConfig.rules"
:key="rule.id"
class="border-t border-border align-top"
>
<td class="px-4 py-3">
<Input
:model-value="rule.name"
class="h-9"
@update:model-value="(value) => updateRule(index, { name: String(value) })"
/>
<div
v-if="rule.system"
class="mt-1 text-[11px] text-muted-foreground"
>
系统预置
</div>
</td>
<td class="px-4 py-3">
<Textarea
:model-value="rule.pattern"
class="min-h-[72px] font-mono text-xs"
@update:model-value="(value) => updateRule(index, { pattern: String(value) })"
/>
</td>
<td class="px-4 py-3">
<Switch
:model-value="rule.enabled"
@update:model-value="(value: boolean) => updateRule(index, { enabled: value })"
/>
</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-1">
<Button
v-if="rule.system"
variant="ghost"
size="icon"
class="h-8 w-8"
title="恢复默认"
@click="resetSystemRule(index)"
>
<RotateCcw class="h-4 w-4" />
</Button>
<Button
v-if="!rule.system"
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive"
title="删除"
@click="removeRule(index)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CardSection>
<CardSection
title="替换类型配置"
description="适用于所有已开启该功能的供应商"
title="占位符配置"
description="配置供应商侧看到的占位符前缀"
>
<div class="space-y-5">
<div
v-for="group in entityGroups"
:key="group.title"
class="rounded-xl border border-border bg-muted/30 p-4"
>
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-foreground">
{{ group.title }}
</h3>
<p class="mt-1 text-xs text-muted-foreground">
已选择 {{ selectedCount(group.entities) }} / {{ group.entities.length }} 项
</p>
</div>
<Button
variant="outline"
size="sm"
@click="setGroupSelection(group.entities, selectedCount(group.entities) !== group.entities.length)"
>
{{ selectedCount(group.entities) === group.entities.length ? '清除本组' : '选择本组' }}
</Button>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<label
v-for="entity in group.entities"
:key="entity.key"
class="flex items-start gap-3 rounded-lg border border-border bg-card/70 px-3 py-3 text-sm transition-colors hover:border-primary/40"
>
<Checkbox
:checked="redactionConfig.entities.includes(entity.key)"
@update:checked="(checked: boolean) => toggleEntity(entity.key, checked)"
/>
<span class="leading-tight">
<span class="block font-medium text-foreground">{{ entity.label }}</span>
<span class="mt-1 block text-xs text-muted-foreground">{{ entity.description }}</span>
</span>
</label>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-[minmax(0,320px)_1fr] md:items-start">
<div class="space-y-2">
<Input
:model-value="redactionConfig.placeholder_prefix"
class="h-9 font-mono uppercase"
maxlength="32"
@update:model-value="(value) => redactionConfig.placeholder_prefix = normalizePlaceholderPrefixInput(String(value))"
/>
<p class="text-xs text-muted-foreground">
仅支持字母、数字、下划线,保存后统一转为大写。
</p>
</div>
<div class="rounded-xl border border-border bg-card px-4 py-3 text-sm text-muted-foreground">
真实姓名、地址、公司名暂不支持自动识别,避免误判影响模型理解。
<div class="rounded-xl border border-border bg-muted/40 px-4 py-3 text-sm">
<span class="text-muted-foreground">示例:</span>
<code class="ml-2 rounded bg-background px-2 py-1 font-mono text-xs text-foreground">
&lt;{{ redactionConfig.placeholder_prefix || 'AETHER' }}:EMAIL:ABCDEFGHIJKLMNOPQRST&gt;
</code>
</div>
</div>
</CardSection>
<CardSection
title="多轮上下文缓存"
description="此时间控制真实值 占位符映射在 Redis 中的缓存窗口窗口内相同敏感值使用相同占位符以减少上游 prompt cache 失效窗口过期后新请求会生成新的占位符缓存写入 Redis必须设置 TTL不写入数据库或日志"
description="此时间控制真实值与占位符映射在 Redis 中的缓存窗口"
>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<button
@@ -161,115 +204,46 @@
</button>
</div>
</CardSection>
<CardSection title="模型提示说明">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<p class="text-sm font-medium text-foreground">
向模型说明占位符含义
</p>
<p class="max-w-3xl text-xs leading-relaxed text-muted-foreground">
开启后Aether 会在发往供应商的请求中插入一条简短内部说明,说明 `&lt;AETHER:TYPE:ID&gt;` 是已保护的真实信息占位符,应按对应类型正常理解和处理,不要要求用户重新提供原文。
</p>
</div>
<Switch
:model-value="redactionConfig.inject_model_instruction"
@update:model-value="(value: boolean) => redactionConfig.inject_model_instruction = value"
/>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RefreshCw, ShieldCheck } from 'lucide-vue-next'
import { Plus, RefreshCw, RotateCcw, ShieldCheck, Trash2 } from 'lucide-vue-next'
import { PageContainer, PageHeader, CardSection } from '@/components/layout'
import Button from '@/components/ui/button.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Input from '@/components/ui/input.vue'
import Switch from '@/components/ui/switch.vue'
import { modulesApi, type ChatPiiRedactionConfig, type ChatPiiRedactionEntity, type ChatPiiRedactionProviderScope } from '@/api/modules'
import Textarea from '@/components/ui/textarea.vue'
import {
CHAT_PII_REDACTION_DEFAULT_RULES,
modulesApi,
type ChatPiiRedactionConfig,
type ChatPiiRedactionRule,
} from '@/api/modules'
import { useModuleStore } from '@/stores/modules'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
interface EntityOption {
key: ChatPiiRedactionEntity
label: string
description: string
}
const defaultConfig: ChatPiiRedactionConfig = {
enabled: false,
provider_scope: 'selected_providers',
entities: [
'email',
'cn_phone',
'global_phone',
'cn_id',
'payment_card',
'ipv4',
'ipv6',
'api_key',
'access_token',
'secret_key',
'bearer_token',
'jwt',
],
rules: CHAT_PII_REDACTION_DEFAULT_RULES.map(rule => ({ ...rule })),
cache_ttl_seconds: 300,
inject_model_instruction: true,
placeholder_prefix: 'AETHER',
}
const scopeOptions: Array<{ value: ChatPiiRedactionProviderScope; label: string; helper: string }> = [
{
value: 'all_providers',
label: '全部供应商',
helper: '所有供应商都会执行替换保护,无需逐个开启。',
},
{
value: 'selected_providers',
label: '指定供应商',
helper: '仅对在供应商管理中开启的供应商生效。',
},
]
const ttlOptions = [
{
value: 300 as const,
label: '5 分钟(默认)',
helper: '更保守,适合短对话或较高隐私偏好;同一敏感信息在 5 分钟内保持相同占位符。',
helper: '适合短对话同一敏感信息在 5 分钟内保持相同占位符。',
},
{
value: 3600 as const,
label: '1 小时',
helper: '适合长多轮对话,可减少重复上下文检测成本;同一敏感信息在 1 小时内保持相同占位符。',
},
]
const entityGroups: Array<{ title: string; entities: EntityOption[] }> = [
{
title: '个人信息',
entities: [
{ key: 'email', label: '邮箱', description: '识别常见电子邮箱地址。' },
{ key: 'cn_phone', label: '手机号/固话', description: '识别中国大陆手机号和固定电话。' },
{ key: 'global_phone', label: '全球电话号码', description: '识别 E.164 风格国际号码。' },
{ key: 'cn_id', label: '中国大陆身份证号', description: '识别通过校验的居民身份证号。' },
{ key: 'payment_card', label: '银行卡号', description: '识别通过 Luhn 校验的卡号。' },
{ key: 'ipv4', label: 'IPv4', description: '识别 IPv4 地址。' },
{ key: 'ipv6', label: 'IPv6', description: '识别 IPv6 地址。' },
],
},
{
title: '密钥与令牌',
entities: [
{ key: 'api_key', label: 'API Key', description: '识别 OpenAI、Anthropic、GitHub、Slack、AWS 等常见密钥。' },
{ key: 'access_token', label: 'Access Token', description: '识别访问令牌形态的敏感凭证。' },
{ key: 'secret_key', label: 'Secret Key', description: '识别高熵密钥和 secret 字段值。' },
{ key: 'bearer_token', label: 'Bearer Token', description: '识别 Authorization Bearer 凭证。' },
{ key: 'jwt', label: 'JWT', description: '识别三段式 JWT 令牌。' },
],
helper: '适合长多轮对话,同一敏感信息在 1 小时内保持相同占位符。',
},
]
@@ -278,20 +252,86 @@ const { success, error } = useToast()
const loading = ref(false)
const saving = ref(false)
const redactionConfig = ref<ChatPiiRedactionConfig>({ ...defaultConfig })
const originalConfig = ref<ChatPiiRedactionConfig>({ ...defaultConfig })
const redactionConfig = ref<ChatPiiRedactionConfig>(cloneConfig(defaultConfig))
const originalConfig = ref<ChatPiiRedactionConfig>(cloneConfig(defaultConfig))
const hasChanges = computed(() => JSON.stringify(redactionConfig.value) !== JSON.stringify(originalConfig.value))
const statusLabel = computed(() => {
const moduleStatus = moduleStore.modules.chat_pii_redaction
if (moduleStatus && !moduleStatus.config_validated) return '配置异常,替换保护未生效'
if (!redactionConfig.value.enabled) return '全局替换开关未开启,所有供应商均不会执行替换保护'
return redactionConfig.value.provider_scope === 'all_providers'
? '全局替换开关已开启,全部供应商都会执行替换保护'
: '全局替换开关已开启,可在供应商中单独启用'
if (moduleStatus && !moduleStatus.config_validated) return '配置异常'
return redactionConfig.value.enabled ? '已开启' : ''
})
function cloneConfig(config: ChatPiiRedactionConfig): ChatPiiRedactionConfig {
return {
enabled: config.enabled,
rules: config.rules.map(rule => ({ ...rule })),
cache_ttl_seconds: config.cache_ttl_seconds,
placeholder_prefix: config.placeholder_prefix || 'AETHER',
}
}
function normalizePlaceholderPrefixInput(value: string): string {
return value.toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 32)
}
function updateRule(index: number, patch: Partial<ChatPiiRedactionRule>) {
const rules = [...redactionConfig.value.rules]
rules[index] = { ...rules[index], ...patch }
redactionConfig.value.rules = rules
}
function addCustomRule() {
redactionConfig.value.rules = [
...redactionConfig.value.rules,
{
id: `custom_${Date.now().toString(36)}`,
name: '自定义规则',
pattern: '',
enabled: true,
system: false,
features: null,
},
]
}
function removeRule(index: number) {
redactionConfig.value.rules = redactionConfig.value.rules.filter((_, itemIndex) => itemIndex !== index)
}
function resetSystemRule(index: number) {
const rule = redactionConfig.value.rules[index]
const defaultRule = CHAT_PII_REDACTION_DEFAULT_RULES.find(item => item.id === rule.id)
if (!defaultRule) return
updateRule(index, { ...defaultRule })
}
function sanitizeRules(): ChatPiiRedactionRule[] | null {
const seen = new Set<string>()
const rules: ChatPiiRedactionRule[] = []
for (const [index, rule] of redactionConfig.value.rules.entries()) {
const id = (rule.id || `custom_${index + 1}`).trim()
const name = rule.name.trim()
const pattern = rule.pattern.trim()
if (!name || !pattern) {
error('规则名称和正则不能为空')
return null
}
const uniqueId = seen.has(id) ? `${id}_${index + 1}` : id
seen.add(uniqueId)
rules.push({
id: uniqueId,
name,
pattern,
enabled: rule.enabled,
system: rule.system === true,
features: rule.features ?? null,
})
}
return rules
}
async function loadConfig() {
loading.value = true
try {
@@ -299,57 +339,42 @@ async function loadConfig() {
modulesApi.getChatPiiRedactionConfig(),
moduleStore.fetchModules(),
])
redactionConfig.value = { ...config }
originalConfig.value = { ...config }
redactionConfig.value = cloneConfig(config)
originalConfig.value = cloneConfig(config)
} catch (err) {
error(parseApiError(err, '加载敏感信息替换保护配置失败'))
log.error('加载敏感信息替换保护配置失败:', err)
error(parseApiError(err, '加载敏感信息保护配置失败'))
log.error('加载敏感信息保护配置失败:', err)
} finally {
loading.value = false
}
}
async function saveConfig() {
const rules = sanitizeRules()
if (!rules) return
const placeholderPrefix = normalizePlaceholderPrefixInput(redactionConfig.value.placeholder_prefix).trim()
if (!placeholderPrefix) {
error('占位符前缀不能为空')
return
}
saving.value = true
try {
const saved = await modulesApi.updateChatPiiRedactionConfig(redactionConfig.value)
redactionConfig.value = { ...saved }
originalConfig.value = { ...saved }
const saved = await modulesApi.updateChatPiiRedactionConfig({
...redactionConfig.value,
placeholder_prefix: placeholderPrefix,
rules,
})
redactionConfig.value = cloneConfig(saved)
originalConfig.value = cloneConfig(saved)
await moduleStore.fetchModules()
success('敏感信息替换保护配置已保存')
success('敏感信息保护配置已保存')
} catch (err) {
error(parseApiError(err, '保存敏感信息替换保护配置失败'))
log.error('保存敏感信息替换保护配置失败:', err)
error(parseApiError(err, '保存敏感信息保护配置失败'))
log.error('保存敏感信息保护配置失败:', err)
} finally {
saving.value = false
}
}
function selectedCount(entities: EntityOption[]) {
return entities.filter((entity) => redactionConfig.value.entities.includes(entity.key)).length
}
function toggleEntity(entity: ChatPiiRedactionEntity, checked: boolean) {
const current = new Set(redactionConfig.value.entities)
if (checked) {
current.add(entity)
} else {
current.delete(entity)
}
redactionConfig.value.entities = defaultConfig.entities.filter((item) => current.has(item))
}
function setGroupSelection(entities: EntityOption[], checked: boolean) {
const current = new Set(redactionConfig.value.entities)
for (const entity of entities) {
if (checked) {
current.add(entity.key)
} else {
current.delete(entity.key)
}
}
redactionConfig.value.entities = defaultConfig.entities.filter((item) => current.has(item))
}
onMounted(loadConfig)
</script>

View File

@@ -450,6 +450,57 @@
{{ editingApiKey ? '留空表示保持当前值,填 0 表示不限并发' : '留空表示不限并发,填 0 也表示不限并发' }}
</p>
</div>
<div class="rounded-lg border border-border/60 bg-muted/30 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-semibold">敏感信息保护</Label>
<p class="mt-1 text-xs text-muted-foreground">
{{ keyRedactionMode === 'inherit' ? '默认跟随账户设置' : '管理员开启功能后生效' }}
</p>
</div>
<div class="flex items-center gap-2">
<Button
size="sm"
:variant="keyRedactionMode === 'inherit' ? 'default' : 'outline'"
@click="keyRedactionMode = 'inherit'"
>
跟随账户
</Button>
<Button
size="sm"
:variant="keyRedactionMode === 'custom' ? 'default' : 'outline'"
@click="keyRedactionMode = 'custom'"
>
单独配置
</Button>
</div>
</div>
<div
v-if="keyRedactionMode === 'custom'"
class="mt-4 flex items-center justify-between gap-4 border-t border-border/50 pt-4"
>
<div>
<Label class="text-sm font-medium">启用保护</Label>
<p class="mt-1 text-xs text-muted-foreground">
只影响此 API Key
</p>
</div>
<Switch v-model="newKeyRedactionEnabled" />
</div>
<div
v-if="keyRedactionMode === 'custom' && newKeyRedactionEnabled"
class="mt-4 flex items-center justify-between gap-4 border-t border-border/50 pt-4"
>
<div>
<Label class="text-sm font-medium">占位符说明</Label>
<p class="mt-1 text-xs text-muted-foreground">
向模型说明占位符含义
</p>
</div>
<Switch v-model="newKeyRedactionInjectNotice" />
</div>
</div>
</div>
<template #footer>
@@ -667,6 +718,7 @@ import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Badge from '@/components/ui/badge.vue'
import Switch from '@/components/ui/switch.vue'
import { Dialog, Pagination } from '@/components/ui'
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
import {
@@ -685,6 +737,11 @@ import { parseApiError } from '@/utils/errorParser'
import { formatRateLimitSimple } from '@/utils/format'
import { parseNumberInput } from '@/utils/form'
import { getErrorStatus } from '@/types/api-error'
import {
hasChatPiiRedactionFeatureSettings,
mergeChatPiiRedactionFeatureSettings,
readChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
const { success, error: showError } = useToast()
@@ -722,6 +779,9 @@ const showInstallDialog = ref(false)
const newKeyName = ref('')
const newKeyRateLimit = ref<number | undefined>(undefined)
const newKeyConcurrentLimit = ref<number | undefined>(undefined)
const keyRedactionMode = ref<'inherit' | 'custom'>('inherit')
const newKeyRedactionEnabled = ref(false)
const newKeyRedactionInjectNotice = ref(true)
const newKeyValue = ref('')
const keyToDelete = ref<ApiKey | null>(null)
const editingApiKey = ref<ApiKey | null>(null)
@@ -801,10 +861,15 @@ function resetInstallCopiedState() {
}
function openEditApiKeyDialog(apiKey: ApiKey) {
const hasRedactionFeature = hasChatPiiRedactionFeatureSettings(apiKey.feature_settings)
const redactionFeature = readChatPiiRedactionFeatureSettings(apiKey.feature_settings)
editingApiKey.value = apiKey
newKeyName.value = apiKey.name || ''
newKeyRateLimit.value = apiKey.rate_limit ?? undefined
newKeyConcurrentLimit.value = apiKey.concurrent_limit ?? undefined
keyRedactionMode.value = hasRedactionFeature ? 'custom' : 'inherit'
newKeyRedactionEnabled.value = redactionFeature.enabled
newKeyRedactionInjectNotice.value = redactionFeature.inject_model_instruction
showCreateDialog.value = true
}
@@ -813,6 +878,9 @@ function openCreateApiKeyDialog() {
newKeyName.value = ''
newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
keyRedactionMode.value = 'inherit'
newKeyRedactionEnabled.value = false
newKeyRedactionInjectNotice.value = true
showCreateDialog.value = true
}
@@ -889,6 +957,9 @@ function closeApiKeyDialog() {
newKeyName.value = ''
newKeyRateLimit.value = undefined
newKeyConcurrentLimit.value = undefined
keyRedactionMode.value = 'inherit'
newKeyRedactionEnabled.value = false
newKeyRedactionInjectNotice.value = true
}
async function saveApiKey() {
@@ -905,6 +976,12 @@ async function saveApiKey() {
name: newKeyName.value,
rate_limit: newKeyRateLimit.value ?? 0,
concurrent_limit: newKeyConcurrentLimit.value,
feature_settings: keyRedactionMode.value === 'custom'
? mergeChatPiiRedactionFeatureSettings(editingApiKey.value.feature_settings, {
enabled: newKeyRedactionEnabled.value,
inject_model_instruction: newKeyRedactionInjectNotice.value,
})
: null,
})
success('API 密钥更新成功')
} else {
@@ -912,6 +989,14 @@ async function saveApiKey() {
name: newKeyName.value,
rate_limit: newKeyRateLimit.value ?? 0,
concurrent_limit: newKeyConcurrentLimit.value,
...(keyRedactionMode.value === 'custom'
? {
feature_settings: mergeChatPiiRedactionFeatureSettings(null, {
enabled: newKeyRedactionEnabled.value,
inject_model_instruction: newKeyRedactionInjectNotice.value,
}),
}
: {}),
})
newKeyValue.value = newKey.key || ''
if (isCreatingFirstApiKey) {

View File

@@ -81,6 +81,49 @@
</form>
</Card>
<Card class="p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-medium text-foreground">
敏感信息保护
</h3>
<p class="text-sm text-muted-foreground mt-1">
管理员开启功能后可默认应用到你的账户和 API Key
</p>
</div>
<Button
variant="outline"
:disabled="savingFeatureSettings || !hasFeatureSettingsChanges"
@click="updateFeatureSettings"
>
{{ savingFeatureSettings ? '保存中...' : '保存' }}
</Button>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
<div>
<Label class="text-sm font-medium">默认启用</Label>
<p class="mt-1 text-xs text-muted-foreground">
未单独配置的 API Key 会跟随此设置
</p>
</div>
<Switch v-model="featureSettingsForm.chatPiiRedactionEnabled" />
</div>
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
<div>
<Label class="text-sm font-medium">占位符说明</Label>
<p class="mt-1 text-xs text-muted-foreground">
向模型说明占位符含义
</p>
</div>
<Switch
v-model="featureSettingsForm.chatPiiRedactionInjectNotice"
:disabled="!featureSettingsForm.chatPiiRedactionEnabled"
/>
</div>
</div>
</Card>
<!-- 密码设置LDAP 用户不显示 -->
<Card
v-if="profile?.auth_source !== 'ldap'"
@@ -626,6 +669,10 @@ import { formatCurrency } from '@/utils/format'
import { getApiUrl } from '@/utils/url'
import { log } from '@/utils/logger'
import { getErrorMessage, getErrorStatus } from '@/types/api-error'
import {
mergeChatPiiRedactionFeatureSettings,
readChatPiiRedactionFeatureSettings,
} from '@/utils/featureSettings'
const authStore = useAuthStore()
const route = useRoute()
@@ -660,7 +707,13 @@ const preferencesForm = ref({
}
})
const featureSettingsForm = ref({
chatPiiRedactionEnabled: false,
chatPiiRedactionInjectNotice: true,
})
const savingProfile = ref(false)
const savingFeatureSettings = ref(false)
const changingPassword = ref(false)
const sessionsLoading = ref(false)
const sessionActionLoading = ref<string | null>(null)
@@ -679,6 +732,7 @@ const emailConfigured = ref(false) // 系统是否配置了邮箱服务
// 原始值,用于检测是否有修改
const originalProfileForm = ref({ email: '', username: '' })
const originalPreferencesForm = ref({ avatar_url: '', bio: '' })
const originalFeatureSettingsForm = ref({ ...featureSettingsForm.value })
// 检测基本信息是否有修改
const hasProfileChanges = computed(() => {
@@ -690,6 +744,13 @@ const hasProfileChanges = computed(() => {
)
})
const hasFeatureSettingsChanges = computed(() => {
return (
featureSettingsForm.value.chatPiiRedactionEnabled !== originalFeatureSettingsForm.value.chatPiiRedactionEnabled ||
featureSettingsForm.value.chatPiiRedactionInjectNotice !== originalFeatureSettingsForm.value.chatPiiRedactionInjectNotice
)
})
const passwordPolicyHint = computed(() => getPasswordPolicyHint(passwordPolicyLevel.value))
const passwordError = computed(() =>
validatePasswordByPolicy(passwordForm.value.new_password, passwordPolicyLevel.value)
@@ -752,14 +813,45 @@ async function loadProfile() {
email: profile.value.email || '',
username: profile.value.username
}
const redactionFeature = readChatPiiRedactionFeatureSettings(profile.value.feature_settings)
featureSettingsForm.value = {
chatPiiRedactionEnabled: redactionFeature.enabled,
chatPiiRedactionInjectNotice: redactionFeature.inject_model_instruction,
}
// 保存原始值
originalProfileForm.value = { ...profileForm.value }
originalFeatureSettingsForm.value = { ...featureSettingsForm.value }
} catch (error) {
log.error('加载个人信息失败:', error)
showError('加载个人信息失败')
}
}
async function updateFeatureSettings() {
savingFeatureSettings.value = true
try {
await meApi.updateProfile({
feature_settings: mergeChatPiiRedactionFeatureSettings(profile.value?.feature_settings, {
enabled: featureSettingsForm.value.chatPiiRedactionEnabled,
inject_model_instruction: featureSettingsForm.value.chatPiiRedactionInjectNotice,
}),
})
if (profile.value) {
profile.value.feature_settings = mergeChatPiiRedactionFeatureSettings(profile.value.feature_settings, {
enabled: featureSettingsForm.value.chatPiiRedactionEnabled,
inject_model_instruction: featureSettingsForm.value.chatPiiRedactionInjectNotice,
})
}
originalFeatureSettingsForm.value = { ...featureSettingsForm.value }
success('敏感信息保护设置已保存')
} catch (err) {
log.error('更新敏感信息保护设置失败:', err)
showError(getErrorMessage(err), '更新敏感信息保护设置失败')
} finally {
savingFeatureSettings.value = false
}
}
async function loadSessions() {
sessionsLoading.value = true
try {