mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
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:
@@ -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="例如:"匿名"、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="例如:"匿名"、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>
|
||||
Reference in New Issue
Block a user