feat(privacy): improve rule editor interactions and layout

Add draggable step cards with inline removal and a dedicated scrollbar. Keep the editor height stable while preview text areas fill the available space, and refine rule actions and preview outcomes.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-15 13:17:54 +08:00
co-authored by Codex
parent f6085206eb
commit 37f7a55427
5 changed files with 333 additions and 171 deletions
@@ -39,12 +39,19 @@ async function test() {
<span class="flex min-h-4 items-center text-cp leading-none font-medium text-cp-text-secondary">最终结果</span><pre class="cp-scrollbar m-0 h-64 overflow-auto rounded-cp bg-cp-fill-quaternary p-3 font-mono text-cp-xs break-all whitespace-pre-wrap">{{ output || '点击测试查看结果' }}</pre>
</div>
</div>
<div v-if="result" class="grid gap-2">
<div v-for="(outcome, index) in result.outcomes" :key="outcome.ruleId" class="flex flex-wrap justify-between gap-2 text-cp-sm">
<span>{{ index + 1 }} · {{ policy.rules.find(rule => rule.id === outcome.ruleId)?.name }}</span>
<span :class="outcome.status === 'skipped' ? 'text-cp-warning-text' : 'text-cp-text-secondary'">{{ outcome.reason || (outcome.status === 'disabled' ? '未启用' : `命中 ${outcome.matches} 处`) }}</span>
</div>
</div>
<ol v-if="result" class="m-0 grid list-none gap-1.5 p-0" aria-label="规则执行结果">
<li v-for="outcome in result.outcomes" :key="outcome.ruleId" class="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 rounded-cp bg-cp-fill-quaternary px-3 py-2.5 text-cp-sm">
<span class="truncate font-medium" :title="policy.rules.find(rule => rule.id === outcome.ruleId)?.name">{{ policy.rules.find(rule => rule.id === outcome.ruleId)?.name }}</span>
<span v-if="outcome.status === 'disabled'" class="text-cp-xs text-cp-text-tertiary">未启用</span>
<span v-else-if="outcome.status === 'skipped'" class="text-cp-xs text-cp-warning-text">已跳过</span>
<span v-else class="inline-flex items-baseline gap-1 whitespace-nowrap text-cp-xs text-cp-text-secondary">
命中 <strong class="text-cp-sm font-emphasis text-cp-text tabular-nums">{{ outcome.matches }}</strong> 处
</span>
<p v-if="outcome.reason" class="col-span-2 m-0 text-cp-xs break-words text-cp-warning-text">
{{ outcome.reason }}
</p>
</li>
</ol>
<p v-if="localError || error" role="alert" class="m-0 text-cp-sm text-cp-error-text">
{{ localError || error }}
</p>
@@ -1,20 +1,23 @@
<script setup lang="ts">
import type { PrivacyRule, PrivacySample } from '@/api/modules/settings/privacy'
import { ZButton, ZCheckbox, ZDialog, ZForm, ZFormItem, ZIconButton, ZInput, ZPopover, ZSelect, ZTextarea } from '@codex-proxy/ui'
import { ArrowDown, ArrowUp, CircleHelp, Play, Plus, Trash2 } from '@lucide/vue'
import type { PrivacyRule, PrivacySample, PrivacyStep } from '@/api/modules/settings/privacy'
import { ZButton, ZDialog, ZFormItem, ZInput, ZTextarea } from '@codex-proxy/ui'
import { Play } from '@lucide/vue'
import { cloneDeep } from 'es-toolkit'
import { computed, ref, shallowRef, useId, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { usePrivacyPreview } from '../../composables/usePrivacyPreview'
import { actions, exampleRequest, fieldsByScope, newRule, newStep, scopes } from './presets'
import { exampleRequest, newRule, newStep } from './presets'
import StepFields from './StepFields.vue'
import StepList from './StepList.vue'
const props = defineProps<{ rule: PrivacyRule | null, maxSteps: number, isNew: boolean, disabled: boolean }>()
const emit = defineEmits<{ apply: [rule: PrivacyRule] }>()
const open = defineModel<boolean>({ required: true })
const fieldPresetId = useId()
const draft = ref<PrivacyRule>(newRule())
const selectedIndex = shallowRef(0)
const step = computed(() => draft.value.steps[selectedIndex.value]!)
const stepOptions = computed(() => draft.value.steps.map((step, index) => ({ value: index, label: `${index + 1} · ${fieldsByScope[step.scope]?.find(field => field.value === step.selector)?.label || step.selector}` })))
const step = computed({
get: () => draft.value.steps[selectedIndex.value]!,
set: value => draft.value.steps[selectedIndex.value] = value,
})
const sample = shallowRef('')
const fixedValues = ref<string[]>(['""'])
const fixedValue = computed({ get: () => fixedValues.value[selectedIndex.value] ?? '""', set: (value: string) => {
@@ -23,24 +26,6 @@ const fixedValue = computed({ get: () => fixedValues.value[selectedIndex.value]
const localError = shallowRef('')
const preview = usePrivacyPreview()
const { loading, error, result } = preview
const usesPattern = computed(() => ['regex_replace', 'rename_key'].includes(step.value.action) || (step.value.action === 'remove_field' && step.value.pattern !== null))
const pattern = computed({ get: () => step.value.pattern ?? '', set: (value: string) => {
step.value.pattern = value
} })
const conditional = computed({ get: () => step.value.pattern !== null, set: (value: boolean) => {
step.value.pattern = value ? '' : null
} })
const fields = computed(() => fieldsByScope[step.value.scope] ?? [])
const fieldChoice = computed({ get: () => fields.value.some(field => field.value === step.value.selector) ? step.value.selector : '', set: (value: string) => {
step.value.selector = value
} })
const selectorPlaceholder = computed(() => ({
turn_metadata: '例如:$.workspaces.*.associated_remote_urls.*',
desktop_git_context: '例如:$.remotes[*].fetchUrl',
environment_text: '填写 $,匹配整段环境文本',
request_body: '例如:$.metadata.workspace',
request_header: '例如:x-client-name',
})[step.value.scope])
const processed = computed(() => result.value ? JSON.stringify({ body: result.value.body, headers: result.value.headers, turnMetadata: result.value.turnMetadata }, null, 2) : '')
const outcome = computed(() => result.value?.outcomes[0])
@@ -57,12 +42,6 @@ watch(open, (value) => {
sample.value = ''
}
})
watch(() => step.value.action, (action) => {
if (action === 'set_value')
step.value.pattern = null
else if (action !== 'remove_field' && step.value.pattern === null)
step.value.pattern = ''
})
watch([draft, sample, fixedValues], () => {
preview.invalidate()
localError.value = ''
@@ -75,24 +54,21 @@ function addStep() {
fixedValues.value.push('""')
selectedIndex.value = draft.value.steps.length - 1
}
function removeStep() {
function removeStep(index: number) {
if (draft.value.steps.length <= 1)
return
const index = selectedIndex.value
selectedIndex.value = Math.max(0, index - 1)
if (index <= selectedIndex.value)
selectedIndex.value = Math.max(0, selectedIndex.value - 1)
draft.value.steps.splice(index, 1)
fixedValues.value.splice(index, 1)
}
function moveStep(offset: number) {
const index = selectedIndex.value
const target = index + offset
if (!draft.value.steps[target])
return
const [moved] = draft.value.steps.splice(index, 1)
draft.value.steps.splice(target, 0, moved!)
const [fixed] = fixedValues.value.splice(index, 1)
fixedValues.value.splice(target, 0, fixed!)
selectedIndex.value = target
function reorderSteps(steps: PrivacyStep[]) {
// JSON 草稿和当前编辑项随步骤一起移动,避免排序后串到其他步骤
const order = steps.map(step => draft.value.steps.indexOf(step))
const nextSelected = order.indexOf(selectedIndex.value)
fixedValues.value = order.map(index => fixedValues.value[index]!)
draft.value.steps = steps
selectedIndex.value = nextSelected
}
function preparedRule() {
const rule = cloneDeep(draft.value)
@@ -136,128 +112,47 @@ async function apply() {
</script>
<template>
<ZDialog v-model="open" :title="isNew ? '添加规则' : '编辑规则'" width="48rem">
<ZDialog v-model="open" :title="isNew ? '添加规则' : '编辑规则'" description="配置字段处理方式,预览隐私信息处理效果" width="64rem">
<div class="grid gap-4">
<ZForm class="sm:grid-cols-2">
<ZFormItem class="sm:col-span-2" label="规则名称" required>
<ZInput v-model="draft.name" placeholder="例如:统一工作区信息" aria-label="规则名称" maxlength="64" />
</ZFormItem>
<ZFormItem class="sm:col-span-2" label="处理步骤">
<template #label-extra>
<ZPopover trigger="hover-click">
<template #reference="{ open: helpOpen }">
<ZIconButton size="small" aria-label="处理步骤说明" :title="undefined" class="-my-2 hover:bg-transparent! active:bg-transparent!" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
<div class="max-w-72 space-y-2 p-3 text-cp-sm text-cp-text-secondary">
<p class="m-0">
处理步骤是一条规则内的具体操作,每一步可分别设置作用范围、字段和处理方式
</p>
<p class="m-0">
步骤按编号依次执行,后一步基于前一步的结果处理,例如先统一用户名,再统一工作区路径
</p>
<p class="m-0">
所有步骤共用规则开关,任一步骤失败会撤销本条规则的全部修改,再按失败策略跳过或阻止请求
</p>
<ZFormItem label="规则名称" required>
<ZInput v-model="draft.name" placeholder="例如:统一工作区信息" aria-label="规则名称" maxlength="64" />
</ZFormItem>
<div class="grid min-w-0 gap-5 md:h-128 md:grid-cols-[18rem_minmax(0,1fr)]">
<StepList v-model="selectedIndex" :steps="draft.steps" :fixed-values="fixedValues" :can-add="draft.steps.length < maxSteps" @add="addStep" @remove="removeStep" @reorder="reorderSteps" />
<!-- 表单按内容排布,预览文本域填充余下高度,切换步骤不会撑动列表或弹窗 -->
<div class="grid h-224 min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] gap-5 sm:h-128 md:h-auto">
<section class="min-w-0" aria-label="当前步骤设置">
<StepFields v-model="step" v-model:fixed-value="fixedValue" />
</section>
<section class="flex min-h-0 flex-col gap-2.5" aria-label="测试预览">
<div class="flex shrink-0 items-center justify-between gap-3">
<h3 class="m-0 text-cp-sm font-emphasis">
整条规则预览
</h3>
<div class="flex items-center gap-2">
<span v-if="outcome" class="text-cp-xs text-cp-text-quaternary">{{ outcome.status === 'skipped' ? '规则已跳过' : `命中 ${outcome.matches} 处` }}</span>
<ZButton size="small" variant="text" :loading="loading" aria-label="测试本条规则" @click="test">
<template #icon>
<Play class="size-3.5" />
</template>测试
</ZButton>
</div>
</ZPopover>
</template>
<div class="flex items-center gap-2">
<ZSelect v-model="selectedIndex" class="min-w-0 flex-1" :options="stepOptions" aria-label="选择处理步骤" />
<ZIconButton :disabled="selectedIndex === 0" aria-label="上移步骤" @click="moveStep(-1)">
<ArrowUp class="size-4" />
</ZIconButton>
<ZIconButton :disabled="selectedIndex === draft.steps.length - 1" aria-label="下移步骤" @click="moveStep(1)">
<ArrowDown class="size-4" />
</ZIconButton>
<ZIconButton :disabled="draft.steps.length >= maxSteps" aria-label="添加步骤" @click="addStep">
<Plus class="size-4" />
</ZIconButton>
<ZIconButton :disabled="draft.steps.length === 1" aria-label="删除步骤" @click="removeStep">
<Trash2 class="size-4" />
</ZIconButton>
</div>
</ZFormItem>
<ZFormItem label="作用范围">
<ZSelect v-model="step.scope" class="w-full" :options="scopes" aria-label="作用范围" />
</ZFormItem>
<ZFormItem label="处理方式">
<ZSelect v-model="step.action" class="w-full" :options="actions" aria-label="处理方式" />
</ZFormItem>
<ZFormItem class="sm:col-span-2" :label="step.scope === 'request_header' ? '请求头名称' : '字段路径'" required>
<template #label-extra>
<ZPopover trigger="hover-click">
<template #reference="{ open: helpOpen }">
<ZIconButton size="small" aria-label="字段路径语法" :title="undefined" class="-my-2 hover:bg-transparent! active:bg-transparent!" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
<div class="max-w-72 p-3 text-cp-sm text-cp-text-secondary">
支持对象成员、双引号键名、数组索引和 * 通配,例如 $["workspaces"]["/home/alex/demo"]<br>环境文本使用 $,请求头直接填写名称
</div>
<div class="grid min-h-0 min-w-0 flex-1 grid-rows-2 gap-3 sm:grid-cols-2 sm:grid-rows-1">
<ZFormItem label="测试样本" class="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] [&>div:last-child]:min-h-0">
<ZTextarea v-model="sample" size="small" resize="none" placeholder="粘贴包含 body、headers、turnMetadata 的请求样本" maxlength="262144" aria-label="测试样本" class="h-full font-mono [&_textarea]:block [&_textarea]:h-full" spellcheck="false" />
</ZFormItem>
<div class="grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] gap-2">
<span class="flex min-h-4 items-center text-cp leading-none font-medium text-cp-text-secondary">处理后</span>
<pre class="cp-scrollbar m-0 min-h-0 overflow-auto rounded-cp bg-cp-fill-quaternary p-3 font-mono text-cp-xs break-all whitespace-pre-wrap" data-preview-output>{{ processed || '点击测试查看结果' }}</pre>
</div>
</ZPopover>
</template>
<div class="flex flex-wrap items-center gap-2 sm:flex-nowrap">
<ZInput v-model="step.selector" :placeholder="selectorPlaceholder" aria-label="字段路径" maxlength="1024" class="min-w-0 flex-1 font-mono" :class="fields.length ? 'basis-full sm:basis-auto' : undefined" spellcheck="false" />
<ZSelect v-if="fields.length" :id="fieldPresetId" v-model="fieldChoice" class="min-w-0 flex-1 sm:w-40 sm:flex-none" :options="fields" aria-label="常用字段" />
</div>
</ZFormItem>
<ZCheckbox v-if="step.action === 'remove_field'" v-model="conditional" class="sm:col-span-2" label="仅删除值匹配正则的字段或数组项" />
<ZFormItem v-if="usesPattern" :class="step.action === 'remove_field' ? 'sm:col-span-2' : undefined" label="匹配表达式">
<ZInput v-model="pattern" :placeholder="step.action === 'rename_key' ? '例如:^/home/[^/]+/' : '例如:private-org'" aria-label="匹配表达式" maxlength="4096" spellcheck="false" class="font-mono" />
</ZFormItem>
<ZFormItem v-if="step.action === 'regex_replace' || step.action === 'rename_key'" label="替换内容">
<template #label-extra>
<ZPopover trigger="hover-click">
<template #reference="{ open: helpOpen }">
<ZIconButton size="small" aria-label="正则语法说明" :title="undefined" class="-my-2 hover:bg-transparent! active:bg-transparent!" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
<div class="max-w-72 p-3 text-cp-sm text-cp-text-secondary">
${1} 或 ${name} 引用捕获组,$$ 表示字面 $<br>使用 Rust regex 语法,不支持前后顾和模式中的反向引用
</div>
</ZPopover>
</template>
<ZInput v-model="step.replacement" :placeholder="step.action === 'rename_key' ? '例如:/home/user/' : '例如:team,留空则清除匹配内容'" aria-label="替换内容" maxlength="4096" spellcheck="false" class="font-mono" />
</ZFormItem>
<ZFormItem v-if="step.action === 'set_value'" class="sm:col-span-2" label="固定值(JSON)">
<ZTextarea v-model="fixedValue" placeholder="例如:&quot;匿名&quot;、null 或 {}" :rows="2" maxlength="4096" aria-label="固定值 JSON" class="font-mono" />
</ZFormItem>
<div v-if="usesPattern" class="flex flex-wrap gap-x-4 gap-y-2 sm:col-span-2">
<ZCheckbox v-if="step.action !== 'remove_field'" v-model="step.replaceAll" label="替换全部匹配" />
<ZCheckbox v-model="step.caseInsensitive" label="忽略大小写" />
<ZCheckbox v-model="step.multiLine" label="多行匹配" />
</div>
<p v-if="localError || error || outcome?.reason" role="alert" class="m-0 shrink-0 text-cp-sm text-cp-error-text">
{{ localError || error || outcome?.reason }}
</p>
</section>
</div>
</ZForm>
<section class="grid gap-2.5" aria-label="测试预览">
<div class="flex items-center justify-between gap-3">
<h3 class="m-0 text-cp-sm font-emphasis">
规则预览
</h3>
<div class="flex items-center gap-2">
<span v-if="outcome" class="text-cp-xs text-cp-text-quaternary">{{ outcome.status === 'skipped' ? '规则已跳过' : `命中 ${outcome.matches} 处` }}</span>
<ZButton size="small" variant="text" :loading="loading" aria-label="测试本条规则" @click="test">
<template #icon>
<Play class="size-3.5" />
</template>测试
</ZButton>
</div>
</div>
<div class="grid min-w-0 gap-3 sm:grid-cols-2">
<ZFormItem label="测试样本">
<ZTextarea v-model="sample" size="small" resize="none" placeholder="粘贴包含 body、headers、turnMetadata 的请求样本" maxlength="262144" aria-label="测试样本" class="font-mono [&_textarea]:block [&_textarea]:h-36" spellcheck="false" />
</ZFormItem>
<div class="grid min-w-0 content-start gap-2">
<span class="flex min-h-4 items-center text-cp leading-none font-medium text-cp-text-secondary">处理后</span>
<pre class="cp-scrollbar m-0 h-36 overflow-auto rounded-cp bg-cp-fill-quaternary p-3 font-mono text-cp-xs break-all whitespace-pre-wrap" data-preview-output>{{ processed || '点击测试查看结果' }}</pre>
</div>
</div>
<p v-if="localError || error || outcome?.reason" role="alert" class="m-0 text-cp-sm text-cp-error-text">
{{ localError || error || outcome?.reason }}
</p>
</section>
</div>
</div>
<template #footer>
<ZButton @click="open = false">
@@ -79,7 +79,7 @@ const columns = defineTableColumns<PrivacyRule>([
{ key: 'name', label: '规则 / 字段', kind: 'identity', size: '3xl' },
{ key: 'scope', label: '作用范围', kind: 'meta', size: 'lg' },
{ key: 'action', label: '处理方式', kind: 'meta', size: 'md' },
{ key: 'actions', label: '', kind: 'actions', size: 'md', fixedWidth: true },
{ key: 'actions', label: '', kind: 'actions', size: 'lg', fixedWidth: true },
])
</script>
@@ -91,7 +91,7 @@ const columns = defineTableColumns<PrivacyRule>([
<p class="sr-only" role="status" aria-live="polite">
{{ announcement }}
</p>
<ZTable :columns="columns" :data="rules" size="small" empty-text="暂无规则,添加一条或从预设开始">
<ZTable :columns="columns" :data="rules" size="small" class="[&_tbody>tr>td:last-child]:pr-2!" empty-text="暂无规则,添加一条或从预设开始">
<template #enabled="{ row }">
<div class="flex items-center justify-center">
<ZSwitch :disabled="disabled" :model-value="row.enabled" :aria-label="`启用规则:${row.name}`" @update:model-value="emit('toggle', row, $event)" />
@@ -0,0 +1,92 @@
<script setup lang="ts">
import type { PrivacyStep } from '@/api/modules/settings/privacy'
import { ZCheckbox, ZForm, ZFormItem, ZIconButton, ZInput, ZPopover, ZSelect, ZTextarea } from '@codex-proxy/ui'
import { CircleHelp } from '@lucide/vue'
import { computed, useId, watch } from 'vue'
import { actions, fieldsByScope, scopes } from './presets'
const step = defineModel<PrivacyStep>({ required: true })
const fixedValue = defineModel<string>('fixedValue', { required: true })
const fieldPresetId = useId()
const usesPattern = computed(() => ['regex_replace', 'rename_key'].includes(step.value.action) || (step.value.action === 'remove_field' && step.value.pattern !== null))
const pattern = computed({ get: () => step.value.pattern ?? '', set: (value: string) => {
step.value.pattern = value
} })
const conditional = computed({ get: () => step.value.pattern !== null, set: (value: boolean) => {
step.value.pattern = value ? '' : null
} })
const fields = computed(() => fieldsByScope[step.value.scope] ?? [])
const fieldChoice = computed({ get: () => fields.value.some(field => field.value === step.value.selector) ? step.value.selector : '', set: (value: string) => {
step.value.selector = value
} })
const selectorPlaceholder = computed(() => ({
turn_metadata: '例如:$.workspaces.*.associated_remote_urls.*',
desktop_git_context: '例如:$.remotes[*].fetchUrl',
environment_text: '填写 $,匹配整段环境文本',
request_body: '例如:$.metadata.workspace',
request_header: '例如:x-client-name',
})[step.value.scope])
watch(() => step.value.action, (action) => {
if (action === 'set_value')
step.value.pattern = null
else if (action !== 'remove_field' && step.value.pattern === null)
step.value.pattern = ''
})
</script>
<template>
<ZForm class="sm:grid-cols-2">
<ZFormItem class="md:[&>div:first-child]:min-h-8" label="作用范围">
<ZSelect v-model="step.scope" class="w-full" :options="scopes" aria-label="作用范围" />
</ZFormItem>
<ZFormItem class="md:[&>div:first-child]:min-h-8" label="处理方式">
<ZSelect v-model="step.action" class="w-full" :options="actions" aria-label="处理方式" />
</ZFormItem>
<ZFormItem class="sm:col-span-2" :label="step.scope === 'request_header' ? '请求头名称' : '字段路径'" required>
<template #label-extra>
<ZPopover trigger="hover-click">
<template #reference="{ open: helpOpen }">
<ZIconButton size="small" aria-label="字段路径语法" :title="undefined" class="-my-2 hover:bg-transparent! active:bg-transparent!" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
<div class="max-w-72 p-3 text-cp-sm text-cp-text-secondary">
支持对象成员、双引号键名、数组索引和 * 通配,例如 $["workspaces"]["/home/alex/demo"]<br>环境文本使用 $,请求头直接填写名称
</div>
</ZPopover>
</template>
<div class="flex flex-wrap items-center gap-2 sm:flex-nowrap">
<ZInput v-model="step.selector" :placeholder="selectorPlaceholder" aria-label="字段路径" maxlength="1024" class="min-w-0 flex-1 font-mono" :class="fields.length ? 'basis-full sm:basis-auto' : undefined" spellcheck="false" />
<ZSelect v-if="fields.length" :id="fieldPresetId" v-model="fieldChoice" class="min-w-0 flex-1 sm:w-40 sm:flex-none" :options="fields" aria-label="常用字段" />
</div>
</ZFormItem>
<ZCheckbox v-if="step.action === 'remove_field'" v-model="conditional" class="sm:col-span-2" label="仅删除值匹配正则的字段或数组项" />
<ZFormItem v-if="usesPattern" :class="step.action === 'remove_field' ? 'sm:col-span-2' : undefined" label="匹配表达式">
<ZInput v-model="pattern" :placeholder="step.action === 'rename_key' ? '例如:^/home/[^/]+/' : '例如:private-org'" aria-label="匹配表达式" maxlength="4096" spellcheck="false" class="font-mono" />
</ZFormItem>
<ZFormItem v-if="step.action === 'regex_replace' || step.action === 'rename_key'" label="替换内容">
<template #label-extra>
<ZPopover trigger="hover-click">
<template #reference="{ open: helpOpen }">
<ZIconButton size="small" aria-label="正则语法说明" :title="undefined" class="-my-2 hover:bg-transparent! active:bg-transparent!" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
<div class="max-w-72 p-3 text-cp-sm text-cp-text-secondary">
${1} 或 ${name} 引用捕获组,$$ 表示字面 $<br>使用 Rust regex 语法,不支持前后顾和模式中的反向引用
</div>
</ZPopover>
</template>
<ZInput v-model="step.replacement" :placeholder="step.action === 'rename_key' ? '例如:/home/user/' : '例如:team,留空则清除匹配内容'" aria-label="替换内容" maxlength="4096" spellcheck="false" class="font-mono" />
</ZFormItem>
<ZFormItem v-if="step.action === 'set_value'" class="sm:col-span-2" label="固定值(JSON)">
<ZTextarea v-model="fixedValue" placeholder="例如:&quot;匿名&quot;、null 或 {}" :rows="2" resize="none" maxlength="4096" aria-label="固定值 JSON" class="font-mono" />
</ZFormItem>
<div v-if="usesPattern" class="flex flex-wrap gap-x-4 gap-y-2 sm:col-span-2">
<ZCheckbox v-if="step.action !== 'remove_field'" v-model="step.replaceAll" label="替换全部匹配" />
<ZCheckbox v-model="step.caseInsensitive" label="忽略大小写" />
<ZCheckbox v-model="step.multiLine" label="多行匹配" />
</div>
</ZForm>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import type { PrivacyStep } from '@/api/modules/settings/privacy'
import { ZIconButton, ZPopover, ZScrollbar } from '@codex-proxy/ui'
import { CircleHelp, GripVertical, Plus, Trash2 } from '@lucide/vue'
import { usePreferredReducedMotion } from '@vueuse/core'
import { moveArrayElement, useSortable } from '@vueuse/integrations/useSortable'
import { computed, nextTick, shallowRef, useId, useTemplateRef, watch } from 'vue'
import { actions, fieldsByScope, scopes } from './presets'
const props = defineProps<{ steps: PrivacyStep[], fixedValues: string[], canAdd: boolean }>()
const emit = defineEmits<{ add: [], remove: [index: number], reorder: [steps: PrivacyStep[]] }>()
const selected = defineModel<number>({ required: true })
const list = useTemplateRef<HTMLOListElement>('list')
const scrollbar = useTemplateRef('scrollbar')
const items = computed(() => props.steps.map((step, index) => {
const field = fieldsByScope[step.scope]?.find(field => field.value && field.value === step.selector)?.label
|| (step.selector === '$' && step.scope !== 'request_header' ? (step.scope === 'environment_text' ? '整段环境文本' : '完整内容') : step.selector.trim() || '未选择字段')
const scope = scopes.find(scope => scope.value === step.scope)?.label
const action = actions.find(action => action.value === step.action)?.label
const effect = step.action === 'set_value'
? `→ ${props.fixedValues[index] ?? '""'}`
: step.action === 'remove_field'
? (step.pattern === null ? '删除匹配的字段或数组项' : `匹配后删除:${step.pattern || '未填写表达式'}`)
: `→ ${step.replacement || '清空匹配内容'}`
return { step, field, scope, action, effect }
}))
const stepKeys = new WeakMap<PrivacyStep, number>()
let nextKey = 0
function stepKey(step: PrivacyStep) {
// 步骤没有持久化 ID,按草稿对象提供稳定的 DOM 身份,不改动接口合同
if (!stepKeys.has(step))
stepKeys.set(step, nextKey++)
return stepKeys.get(step)!
}
const reorderHelpId = useId()
const announcement = shallowRef('')
const preferredMotion = usePreferredReducedMotion()
const animation = computed(() => preferredMotion.value === 'reduce' ? 0 : 200)
const sortingDisabled = computed(() => props.steps.length < 2)
const sortableSteps = computed({
get: () => props.steps,
set: steps => emit('reorder', steps),
})
const { option: setSortableOption } = useSortable(list, sortableSteps, {
watchElement: true,
handle: '[data-step-handle]',
draggable: 'li',
animation: animation.value,
disabled: sortingDisabled.value,
easing: 'cubic-bezier(0.2, 0, 0, 1)',
ghostClass: 'opacity-40',
onEnd: (event) => {
if (event.newIndex !== undefined && event.oldIndex !== event.newIndex)
void nextTick(() => focusMovedStep(event.newIndex!))
},
})
watch([list, sortingDisabled, animation], async () => {
await nextTick()
setSortableOption('disabled', sortingDisabled.value)
setSortableOption('animation', animation.value)
}, { flush: 'post' })
function revealStep(index: number) {
const container = scrollbar.value?.wrapRef
const item = list.value?.children[index] as HTMLElement | undefined
if (!container || !item)
return
// 仅滚动步骤列表,保持编辑表单和弹窗的位置稳定
const top = item.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop
if (top < container.scrollTop)
container.scrollTop = top
else if (top + item.offsetHeight > container.scrollTop + container.clientHeight)
container.scrollTop = top + item.offsetHeight - container.clientHeight
}
watch(selected, () => revealStep(selected.value), { flush: 'post' })
async function focusMovedStep(index: number) {
await nextTick()
list.value?.children[index]?.querySelector<HTMLButtonElement>('[data-step-handle]')?.focus({ preventScroll: true })
revealStep(index)
announcement.value = `步骤已移到第 ${index + 1} 位`
}
function moveWithKeyboard(index: number, offset: -1 | 1) {
const target = index + offset
if (!props.steps[target])
return
moveArrayElement(sortableSteps, index, target)
void nextTick(() => focusMovedStep(target))
}
</script>
<template>
<section class="grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)]" aria-label="处理步骤">
<div class="mb-1 flex min-h-8 items-center justify-between gap-2 pr-5 pl-1">
<div class="flex items-center gap-1">
<h3 class="m-0 text-cp leading-none font-medium text-cp-text-secondary">
处理步骤
</h3>
<ZPopover trigger="hover-click">
<template #reference="{ open: helpOpen }">
<ZIconButton size="small" aria-label="处理步骤说明" :title="undefined" class="size-5! hover:bg-transparent! active:bg-transparent!" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
<div class="max-w-72 space-y-2 p-3 text-cp-sm text-cp-text-secondary">
<p class="m-0">
拖动手柄调整执行顺序,也可聚焦手柄后使用上下方向键移动
</p>
<p class="m-0">
每一步可分别设置作用范围、字段和处理方式,后一步基于前一步的结果处理
</p>
<p class="m-0">
所有步骤共用规则开关,任一步骤失败会撤销本条规则的全部修改,再按失败策略跳过或阻止请求
</p>
</div>
</ZPopover>
</div>
<ZIconButton size="small" aria-label="添加步骤" class="bg-cp-fill-quaternary! hover:bg-cp-fill-tertiary!" :disabled="!canAdd" @click="emit('add')">
<Plus class="size-3.5" />
</ZIconButton>
</div>
<p :id="reorderHelpId" class="sr-only">
拖动手柄调整步骤顺序,也可聚焦手柄后使用上下方向键移动
</p>
<p class="sr-only" role="status" aria-live="polite">
{{ announcement }}
</p>
<div class="relative h-52 min-h-0 md:h-auto">
<div class="absolute inset-y-0 right-1 left-0">
<ZScrollbar ref="scrollbar">
<ol ref="list" class="m-0 grid list-none gap-1 p-1 pr-4">
<li
v-for="(item, index) in items" :key="stepKey(item.step)"
class="relative min-w-0 rounded-cp transition-colors motion-reduce:transition-none"
:class="selected === index ? 'bg-cp-primary-container text-cp-primary-on-container hover:bg-cp-primary-container-hover' : 'bg-cp-fill-quaternary text-cp-text hover:bg-cp-bg-text-hover'"
>
<ZIconButton
size="small" :disabled="sortingDisabled"
:aria-label="`拖动步骤 ${index + 1}:${item.field}`" :aria-describedby="reorderHelpId"
data-step-handle class="absolute top-1 left-1 touch-none cursor-grab active:cursor-grabbing disabled:cursor-not-allowed"
@keydown.up.stop.prevent="moveWithKeyboard(index, -1)"
@keydown.down.stop.prevent="moveWithKeyboard(index, 1)"
>
<GripVertical class="size-3.5" />
</ZIconButton>
<button
type="button"
class="grid w-full min-w-0 cursor-pointer gap-0.5 rounded-cp border-0 bg-transparent py-2 pr-2 pl-9 text-left outline-none focus-visible:ring-2 focus-visible:ring-cp-control-outline"
:aria-current="selected === index ? 'step' : undefined"
:aria-label="`步骤 ${index + 1}:${item.field},${item.scope},${item.action},${item.effect}`"
@click="selected = index"
>
<span class="truncate pr-7 text-cp-sm leading-5 font-emphasis" :title="item.field">{{ item.field }}</span>
<span class="truncate text-cp-xs leading-4" :class="selected === index ? 'text-cp-primary-on-container' : 'text-cp-text-secondary'" :title="`${item.scope} · ${item.action}`">{{ item.scope }} · {{ item.action }}</span>
<span class="truncate font-mono text-cp-xs leading-4" :class="selected === index ? 'text-cp-primary-on-container' : 'text-cp-text-tertiary'" :title="item.effect">{{ item.effect }}</span>
</button>
<ZIconButton size="small" class="absolute top-1 right-1" :disabled="steps.length === 1" :aria-label="`删除步骤 ${index + 1}:${item.field}`" @click="emit('remove', index)">
<Trash2 class="size-3.5" />
</ZIconButton>
</li>
</ol>
</ZScrollbar>
</div>
</div>
</section>
</template>