481243b958
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>
361 lines
10 KiB
Vue
361 lines
10 KiB
Vue
<!--
|
||
提示词四件套编辑(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>
|