Files
ai-kefu-admin/admin/src/views/config/center/PromptsPane.vue
T
浅念 481243b958 feat: 配置中心-版本历史(列表/两版对比/回滚/组级重置出厂)
Task 7d——Phase 3 配置中心四连收尾:

- API(api/config.ts):fetchConfigVersions / fetchConfigVersionDetail /
  rollbackConfig / resetConfigGroup 四函数补齐(骨架端点与类型已就位;
  响应形态对照 config_routes 实测——列表无 config 本体、详情整份+掩码、
  reset 响应与 PUT 分组保存同形)
- VersionsPane:版本 tab 实装(导航占位转正)——列表倒序(版本/时间/
  作者/备注,当前版标记)+ 详情抽屉整份 JSON;勾两版(限两个,第三个
  自动撤销最早勾选行)并排等宽 JSON 对比(spec §7.3 最低形态,范围
  下拉可聚焦单组——wall 20KB+ 场景);回滚确认框明示「将产生新版本,
  历史不变」→ 响应内新快照直接回写页头版本/快照/品牌表单,无需再 GET
- 组级重置出厂:六个已实装编辑卡(品牌/提示词/词表/邮件/推送/AI)头部
  危险色小按钮 → reset.ts 共用确认入口(明示组名与「恢复出厂档案值」)
  → POST config/{group}/reset → 表单/密钥态/版本刷新(RESET_GROUP_
  LABELS 九组镜像后端 CONFIG_GROUPS,含待实装的联系方式/引擎/杂项)
- 测试:vitest 120(+13——API 四函数调用形态 5 条 + model 辅助 8 条);
  后端不动 595;冒烟 jiancai:v1-v10 列表 → v1/v10 并排对比(push 组
  __SET__ 掩码可见)→ 回滚 v1 产 v11 且品牌回 v1 值 → 改占位存 v12 →
  重置品牌产 v13 表单回档案值 → 回滚 v10 复原现场(v14)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 21:39:16 +08:00

361 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
提示词四件套编辑(Phase 3 Task 7b;消费 config/center/model.ts 纯逻辑)
- GET config 回显 prompts 组(四键 dict)→ 四个等宽 textarea 编辑 →
PUT config/prompts 整组提交(prompts 是 AI 行为源头——保存备注**必填**)
- 字节数实时提示(UTF-8 口径 TextEncoder,与后端 len(encode) 一致):
company_facts 50KB / 其余 100KB;超限红显 + 禁存(后端同样硬拒)
- system.md 编辑器旁固定 $DEFLECTION_POOL 说明卡:该占位符由安全词表
迂回话术池自动注入,勿在提示词里手写话术清单(话术单一事实源)
- 重置出厂(Task 7d):头部危险色小按钮 → 确认框 → POST config/prompts/reset
→ emit saved 回写快照组 → watch 重置表单为出厂档案值
- 400 校验 detail(带字段路径)内联展示在保存按钮上方
-->
<template>
<ElCard shadow="never" class="form-card prompts-pane">
<template #header>
<div class="card-header">
<span class="card-title">提示词(prompts 组 · 四件套整组替换)</span>
<div class="header-side">
<span class="card-sub">保存即写新版本并热生效——备注必填(AI 行为源头)</span>
<ElButton
size="small"
type="danger"
plain
:loading="resetting"
@click="onReset"
>
重置出厂
</ElButton>
</div>
</div>
</template>
<ElAlert
v-if="actionError"
type="error"
:closable="true"
:title="actionError"
class="pane-error"
@close="actionError = ''"
/>
<!-- system.md + $DEFLECTION_POOL 说明卡 -->
<div class="prompt-block">
<div class="prompt-head">
<span class="prompt-label mono">{{ meta.key }}.md</span>
<span class="prompt-name">{{ meta.label }}</span>
<span class="prompt-bytes" :class="{ over: byteInfos.system.over }">
{{ byteInfos.system.label }}(UTF-8 字节)
</span>
</div>
<p class="prompt-hint">{{ meta.hint }}</p>
<div class="system-row">
<ElInput
v-model="draft.system"
type="textarea"
:rows="meta.rows"
resize="vertical"
class="mono-input"
/>
<div class="deflection-note">
<p class="note-title">$DEFLECTION_POOL 占位符</p>
<p class="note-text">
此占位符自动注入安全词表(wall 组)deflections 迂回话术池,
<b>勿在提示词里手写话术清单</b>——改话术请去「安全词表」tab,
这里只保留 <span class="mono">$DEFLECTION_POOL</span> 占位符本身。
</p>
</div>
</div>
</div>
<!-- 其余三件 -->
<div v-for="item in restMeta" :key="item.key" class="prompt-block">
<div class="prompt-head">
<span class="prompt-label mono">{{ item.key }}.md</span>
<span class="prompt-name">{{ item.label }}</span>
<span class="prompt-bytes" :class="{ over: byteInfos[item.key].over }">
{{ byteInfos[item.key].label }}(UTF-8 字节)
</span>
</div>
<p class="prompt-hint">{{ item.hint }}</p>
<ElInput
v-model="draft[item.key]"
type="textarea"
:rows="item.rows"
resize="vertical"
class="mono-input"
/>
<ElAlert
v-if="byteInfos[item.key].over"
type="error"
:closable="false"
class="over-alert"
:title="`${item.key} 超过 ${formatKB(byteInfos[item.key].cap)} 上限——每轮整段拼入 system prompt,请精炼(后端将拒绝保存)`"
/>
</div>
<div class="save-bar">
<ElInput
v-model="saveComment"
maxlength="200"
placeholder="必填:本次提示词改了什么(写入版本历史 comment)"
class="comment-input"
/>
<ElButton
type="primary"
:loading="saving"
:disabled="!canSave"
@click="onSave"
>
保存提示词组(整组提交,热生效)
</ElButton>
</div>
</ElCard>
</template>
<script setup lang="ts">
import {
PROMPTS_KEYS,
PROMPTS_META,
formatKB,
normalizePromptsGroup,
promptByteInfo,
promptsOverCap,
type PromptsDraft
} from './model'
import { putConfigGroup } from '@/api/config'
import { confirmAndResetGroup } from './reset'
defineOptions({ name: 'PromptsPane' })
const props = defineProps<{
/** 租户 slug(保存目标) */
slug: string
/** GET config 回显的 prompts 组(原样快照;null = 尚未加载) */
group: Record<string, unknown> | null | undefined
}>()
const emit = defineEmits<{
/** 保存成功:父层更新版本号与快照组 */
(e: 'saved', payload: { version: number; group: 'prompts'; config: Record<string, unknown> }): void
}>()
// ---- 编辑态(group 引用变化 = 租户切换/重载 → 重置表单) ----------------------------
const draft = reactive<PromptsDraft>(normalizePromptsGroup(undefined))
const saveComment = ref('')
const saving = ref(false)
const actionError = ref('')
watch(
() => props.group,
(group) => {
Object.assign(draft, normalizePromptsGroup(group))
saveComment.value = ''
actionError.value = ''
},
{ immediate: true }
)
// ---- 字节实时提示 -------------------------------------------------------------------
const byteInfos = computed(() => {
const out = {} as Record<(typeof PROMPTS_KEYS)[number], ReturnType<typeof promptByteInfo>>
for (const key of PROMPTS_KEYS) {
out[key] = promptByteInfo(key, draft[key])
}
return out
})
/** system 单列渲染(旁边挂 $DEFLECTION_POOL 说明卡),其余三件走列表 */
const meta = PROMPTS_META[0]
const restMeta = PROMPTS_META.slice(1)
const canSave = computed(
() => saveComment.value.trim() !== '' && !promptsOverCap(draft)
)
// ---- 保存(整组 PUT;备注必填) ------------------------------------------------------
async function onSave(): Promise<void> {
if (!canSave.value || saving.value) return
actionError.value = ''
saving.value = true
try {
const res = await putConfigGroup(
props.slug,
'prompts',
{
system: draft.system,
company_facts: draft.company_facts,
extractor: draft.extractor,
evaluator: draft.evaluator
},
saveComment.value.trim(),
{ showErrorMessage: false }
)
saveComment.value = ''
ElMessage.success(`提示词已保存(版本 v${res.version},热生效)`)
emit('saved', { version: res.version, group: 'prompts', config: res.config })
} catch (error) {
// 400 校验 detail(带字段路径,如 "prompts.company_facts: 内容 … 超过上限 …")
actionError.value = error instanceof Error ? error.message : '保存失败'
} finally {
saving.value = false
}
}
// ---- 重置出厂(Task 7d:确认 → POST reset → emit saved 回写快照组 →
// group 引用变化触发上方 watch 重置表单为出厂档案值) -------------------------------
const resetting = ref(false)
async function onReset(): Promise<void> {
if (resetting.value) return
resetting.value = true
try {
const res = await confirmAndResetGroup(props.slug, 'prompts')
if (!res) return // 用户取消确认
ElMessage.success(`提示词已重置出厂(版本 v${res.version},热生效)`)
emit('saved', { version: res.version, group: 'prompts', config: res.config })
} catch (error) {
actionError.value = error instanceof Error ? error.message : '重置失败'
} finally {
resetting.value = false
}
}
</script>
<style scoped lang="scss">
.prompts-pane {
flex: 1;
min-width: 0;
width: 100%;
}
.card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
.card-title {
font-weight: 600;
}
.card-sub {
font-size: 12px;
color: var(--art-gray-400);
}
// 头部右列:副标题 + 重置出厂小按钮(Task 7d)
.header-side {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
}
.pane-error {
margin-bottom: 12px;
}
.prompt-block {
margin-bottom: 18px;
}
.prompt-head {
display: flex;
gap: 10px;
align-items: baseline;
margin-bottom: 4px;
.prompt-label {
font-size: 13px;
font-weight: 600;
color: var(--el-color-primary);
}
.prompt-name {
font-size: 13px;
}
.prompt-bytes {
margin-left: auto;
font-size: 12px;
color: var(--art-gray-400);
&.over {
color: var(--el-color-danger);
font-weight: 600;
}
}
}
.prompt-hint {
margin: 0 0 6px;
font-size: 12px;
color: var(--art-gray-400);
}
.system-row {
display: flex;
gap: 12px;
align-items: stretch;
.mono-input {
flex: 1;
min-width: 0;
}
.deflection-note {
width: 220px;
flex-shrink: 0;
padding: 10px 12px;
border: 1px solid var(--el-color-warning-light-7);
border-radius: 6px;
background: var(--el-color-warning-light-9);
.note-title {
margin: 0 0 6px;
font-size: 12px;
font-weight: 600;
color: var(--el-color-warning);
}
.note-text {
margin: 0;
font-size: 12px;
line-height: 1.7;
color: var(--art-gray-500);
}
}
}
.over-alert {
margin-top: 6px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
:deep(.mono-input textarea) {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
line-height: 1.7;
}
.save-bar {
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
.comment-input {
width: 360px;
}
}
</style>