|
|
<!-- 规则新建 / 编辑页 -->
|
|
|
<template>
|
|
|
<div class="rule-edit-page">
|
|
|
<div class="page-header">
|
|
|
<ElButton text :icon="ArrowLeft" @click="handleBack">返回</ElButton>
|
|
|
<span class="page-title">{{ isEdit ? "编辑规则" : "新建规则" }}</span>
|
|
|
</div>
|
|
|
|
|
|
<!-- 左右布局:基础信息 + 提示词模板 -->
|
|
|
<div class="edit-body">
|
|
|
<!-- 基础信息 -->
|
|
|
<ElCard class="form-card form-card--left" shadow="never">
|
|
|
<template #header>
|
|
|
<span class="section-title">基础信息</span>
|
|
|
</template>
|
|
|
<ElForm
|
|
|
ref="formRef"
|
|
|
:model="form"
|
|
|
:rules="rules"
|
|
|
label-width="110px"
|
|
|
label-position="right"
|
|
|
>
|
|
|
<ElFormItem label="规则名称" prop="name" required>
|
|
|
<ElInput
|
|
|
v-model="form.name"
|
|
|
placeholder="请输入规则名称"
|
|
|
:maxlength="50"
|
|
|
show-word-limit
|
|
|
/>
|
|
|
</ElFormItem>
|
|
|
|
|
|
<ElFormItem label="规则分类" prop="category_id" required>
|
|
|
<ElTreeSelect
|
|
|
v-model="form.category_id"
|
|
|
:data="categoryTree"
|
|
|
:props="treeSelectProps"
|
|
|
node-key="id"
|
|
|
check-strictly
|
|
|
placeholder="请选择规则分类"
|
|
|
filterable
|
|
|
clearable
|
|
|
style="width: 100%"
|
|
|
/>
|
|
|
</ElFormItem>
|
|
|
|
|
|
<ElFormItem label="规则描述" prop="description">
|
|
|
<ElInput
|
|
|
v-model="form.description"
|
|
|
type="textarea"
|
|
|
:rows="4"
|
|
|
placeholder="请输入规则描述(可选)"
|
|
|
:maxlength="200"
|
|
|
show-word-limit
|
|
|
/>
|
|
|
</ElFormItem>
|
|
|
|
|
|
<ElFormItem label="状态" prop="status" required>
|
|
|
<ElRadioGroup v-model="form.status">
|
|
|
<ElRadio :value="0">启用</ElRadio>
|
|
|
<ElRadio :value="1">停用</ElRadio>
|
|
|
</ElRadioGroup>
|
|
|
</ElFormItem>
|
|
|
</ElForm>
|
|
|
</ElCard>
|
|
|
|
|
|
<!-- 提示词模板 -->
|
|
|
<ElCard class="form-card form-card--right" shadow="never">
|
|
|
<template #header>
|
|
|
<span class="section-title">提示词模板</span>
|
|
|
</template>
|
|
|
<div class="prompt-section">
|
|
|
<!-- 提示栏 -->
|
|
|
<div class="prompt-hint-bar">
|
|
|
<span class="hint-item">
|
|
|
变量格式:<code class="var-code">${变量名}</code>,如
|
|
|
<code class="var-code">${合同全文内容}</code>;
|
|
|
</span>
|
|
|
<span class="hint-item">
|
|
|
知识库引用:<code class="kb-code">@知识库/文档名称</code>,如
|
|
|
<code class="kb-code">@知识库/GB/T 50326-2018</code>
|
|
|
</span>
|
|
|
</div>
|
|
|
|
|
|
<!-- 代码编辑器样式 textarea -->
|
|
|
<div class="code-editor" ref="editorRef">
|
|
|
<div class="line-numbers">
|
|
|
<span
|
|
|
v-for="n in lineCount"
|
|
|
:key="n"
|
|
|
class="line-num"
|
|
|
>{{ n }}</span>
|
|
|
</div>
|
|
|
<textarea
|
|
|
ref="textareaRef"
|
|
|
v-model="form.prompt_template"
|
|
|
class="code-textarea"
|
|
|
spellcheck="false"
|
|
|
placeholder="请输入提示词模板。输入 @ 可弹出知识库引用选择器"
|
|
|
@input="handlePromptInput"
|
|
|
@keydown="handlePromptKeydown"
|
|
|
@scroll="syncScroll"
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
<!-- 底部状态栏 -->
|
|
|
<div class="editor-footer">
|
|
|
<span class="tab-hint">按 Tab 键插入缩进</span>
|
|
|
<span class="char-count">{{ form.prompt_template.length }} 字符</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</ElCard>
|
|
|
</div>
|
|
|
|
|
|
<div class="footer-actions">
|
|
|
<ElButton @click="handleBack">取消</ElButton>
|
|
|
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
|
|
|
{{ isEdit ? "保存" : "创建" }}
|
|
|
</ElButton>
|
|
|
</div>
|
|
|
|
|
|
<!-- 提示词模板 @ 引用弹窗 -->
|
|
|
<PromptMentionDialog v-model="mentionVisible" @confirm="handleMentionConfirm" />
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
defineOptions({ name: "RuleEdit" });
|
|
|
|
|
|
import { ref, reactive, computed, nextTick, onMounted } from "vue";
|
|
|
import { useRoute, useRouter } from "vue-router";
|
|
|
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
|
|
import { ArrowLeft } from "@element-plus/icons-vue";
|
|
|
import { RuleAPI } from "@/api/module_rule/rule";
|
|
|
import { useRuleCategory } from "../useRuleCategory";
|
|
|
import type { RuleForm } from "@/api/module_rule/rule";
|
|
|
import PromptMentionDialog from "./PromptMentionDialog.vue";
|
|
|
|
|
|
const route = useRoute();
|
|
|
const router = useRouter();
|
|
|
const { tree: categoryTree, loadTree } = useRuleCategory();
|
|
|
|
|
|
const treeSelectProps = {
|
|
|
label: "name",
|
|
|
children: "children",
|
|
|
};
|
|
|
|
|
|
const ruleId = computed(() => {
|
|
|
const v = route.query.id;
|
|
|
if (!v) return undefined;
|
|
|
const n = Number(v);
|
|
|
return Number.isFinite(n) ? n : undefined;
|
|
|
});
|
|
|
const isEdit = computed(() => ruleId.value != null);
|
|
|
|
|
|
const formRef = ref<FormInstance | null>(null);
|
|
|
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
|
|
const editorRef = ref<HTMLElement | null>(null);
|
|
|
const submitting = ref(false);
|
|
|
|
|
|
const form = reactive<RuleForm>({
|
|
|
id: undefined,
|
|
|
name: "",
|
|
|
category_id: null,
|
|
|
description: "",
|
|
|
prompt_template: "",
|
|
|
status: 0,
|
|
|
});
|
|
|
|
|
|
const rules: FormRules = {
|
|
|
name: [
|
|
|
{ required: true, message: "请输入规则名称", trigger: "blur" },
|
|
|
{ max: 128, message: "最多 128 个字符", trigger: "blur" },
|
|
|
],
|
|
|
category_id: [{ required: true, message: "请选择规则分类", trigger: "change" }],
|
|
|
prompt_template: [
|
|
|
{ required: true, message: "请输入提示词模板", trigger: "blur" },
|
|
|
],
|
|
|
status: [{ required: true, message: "请选择状态", trigger: "change" }],
|
|
|
};
|
|
|
|
|
|
// 行数统计(用于行号显示)
|
|
|
const lineCount = computed(() => {
|
|
|
const text = form.prompt_template || "";
|
|
|
return Math.max(text.split("\n").length, 1);
|
|
|
});
|
|
|
|
|
|
function syncScroll() {
|
|
|
if (!textareaRef.value || !editorRef.value) return;
|
|
|
const lineNums = editorRef.value.querySelector(".line-numbers") as HTMLElement | null;
|
|
|
if (lineNums && textareaRef.value) {
|
|
|
lineNums.scrollTop = textareaRef.value.scrollTop;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ============ 提示词模板 @ 引用 ============
|
|
|
const mentionVisible = ref(false);
|
|
|
let mentionStart: number = -1;
|
|
|
|
|
|
function handlePromptInput() {
|
|
|
nextTick(() => {
|
|
|
const el = textareaRef.value;
|
|
|
if (!el) return;
|
|
|
const caret = el.selectionStart;
|
|
|
const value = form.prompt_template;
|
|
|
if (value && caret > 0) {
|
|
|
const ch = value[caret - 1];
|
|
|
const prevCh = caret - 2 >= 0 ? value[caret - 2] : "";
|
|
|
if (ch === "@" && prevCh !== "@") {
|
|
|
mentionStart = caret - 1;
|
|
|
mentionVisible.value = true;
|
|
|
}
|
|
|
}
|
|
|
syncScroll();
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function handlePromptKeydown(e: Event | KeyboardEvent) {
|
|
|
const ev = e as KeyboardEvent;
|
|
|
// Tab 键插入缩进
|
|
|
if (ev.key === "Tab" && !mentionVisible.value) {
|
|
|
ev.preventDefault();
|
|
|
const el = textareaRef.value;
|
|
|
if (!el) return;
|
|
|
const start = el.selectionStart;
|
|
|
const end = el.selectionEnd;
|
|
|
const value = form.prompt_template;
|
|
|
form.prompt_template = value.slice(0, start) + " " + value.slice(end);
|
|
|
nextTick(() => {
|
|
|
if (el) {
|
|
|
el.focus();
|
|
|
const pos = start + 2;
|
|
|
el.setSelectionRange(pos, pos);
|
|
|
syncScroll();
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function handleMentionConfirm(text: string) {
|
|
|
if (mentionStart < 0) {
|
|
|
form.prompt_template = `${form.prompt_template}${text}`;
|
|
|
return;
|
|
|
}
|
|
|
const before = form.prompt_template.slice(0, mentionStart);
|
|
|
const after = form.prompt_template.slice(mentionStart + 1);
|
|
|
form.prompt_template = `${before}${text}${after}`;
|
|
|
mentionStart = -1;
|
|
|
nextTick(() => {
|
|
|
const el = textareaRef.value;
|
|
|
if (el) {
|
|
|
const pos = before.length + text.length;
|
|
|
el.focus();
|
|
|
el.setSelectionRange(pos, pos);
|
|
|
syncScroll();
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// ============ 加载详情 ============
|
|
|
async function loadDetail() {
|
|
|
if (!ruleId.value) return;
|
|
|
try {
|
|
|
const resp = await RuleAPI.detail(ruleId.value);
|
|
|
const data = resp?.data?.data;
|
|
|
if (data) {
|
|
|
form.id = data.id;
|
|
|
form.name = data.name ?? "";
|
|
|
form.category_id = data.category_id ?? null;
|
|
|
form.description = data.description ?? "";
|
|
|
form.prompt_template = data.prompt_template ?? "";
|
|
|
form.status = (data.status ?? 0) as 0 | 1;
|
|
|
}
|
|
|
} catch (e) {
|
|
|
// 全局拦截器已处理
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ============ 提交 ============
|
|
|
async function handleSubmit() {
|
|
|
try {
|
|
|
await formRef.value?.validate();
|
|
|
} catch {
|
|
|
return;
|
|
|
}
|
|
|
submitting.value = true;
|
|
|
try {
|
|
|
const payload: RuleForm = {
|
|
|
id: form.id,
|
|
|
name: form.name.trim(),
|
|
|
category_id: form.category_id ?? null,
|
|
|
description: form.description?.trim() || "",
|
|
|
prompt_template: form.prompt_template,
|
|
|
status: form.status,
|
|
|
};
|
|
|
if (isEdit.value && form.id) {
|
|
|
await RuleAPI.update(form.id, payload);
|
|
|
ElMessage.success("保存成功");
|
|
|
} else {
|
|
|
await RuleAPI.create(payload);
|
|
|
ElMessage.success("创建成功");
|
|
|
}
|
|
|
router.push("/rule/list");
|
|
|
} catch (e) {
|
|
|
} finally {
|
|
|
submitting.value = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function handleBack() {
|
|
|
router.push("/rule/list");
|
|
|
}
|
|
|
|
|
|
onMounted(async () => {
|
|
|
await loadTree();
|
|
|
if (isEdit.value) {
|
|
|
await loadDetail();
|
|
|
}
|
|
|
});
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
.rule-edit-page {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
height: 100%;
|
|
|
// padding: 16px;
|
|
|
gap: 12px;
|
|
|
overflow: auto;
|
|
|
}
|
|
|
|
|
|
.page-header {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 8px;
|
|
|
flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
.page-title {
|
|
|
font-size: 16px;
|
|
|
font-weight: 600;
|
|
|
color: #111827;
|
|
|
}
|
|
|
|
|
|
/* 左右布局主体 */
|
|
|
.edit-body {
|
|
|
flex: 1;
|
|
|
display: flex;
|
|
|
gap: 12px;
|
|
|
min-height: 0;
|
|
|
align-items: flex-start;
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
|
|
|
.form-card {
|
|
|
border-radius: 8px;
|
|
|
border: 1px solid #e5e7eb;
|
|
|
|
|
|
:deep(.el-card__header) {
|
|
|
padding: 12px 20px;
|
|
|
border-bottom: 1px solid #f3f4f6;
|
|
|
}
|
|
|
|
|
|
:deep(.el-card__body) {
|
|
|
padding: 20px 24px 0px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.form-card--left {
|
|
|
width: 420px;
|
|
|
min-width: 420px;
|
|
|
flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
.form-card--right {
|
|
|
flex: 1;
|
|
|
min-width: 0;
|
|
|
height: 100%;
|
|
|
}
|
|
|
|
|
|
.section-title {
|
|
|
font-size: 15px;
|
|
|
font-weight: 600;
|
|
|
color: #111827;
|
|
|
}
|
|
|
|
|
|
/* 提示词模板区域 */
|
|
|
.prompt-section {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: 0;
|
|
|
height: 100%;
|
|
|
}
|
|
|
|
|
|
.prompt-hint-bar {
|
|
|
background: #f5f7fa;
|
|
|
border: 1px solid #e5e7eb;
|
|
|
border-bottom: none;
|
|
|
border-radius: 6px 6px 0 0;
|
|
|
padding: 8px 14px;
|
|
|
font-size: 12px;
|
|
|
color: #6b7280;
|
|
|
line-height: 1.8;
|
|
|
}
|
|
|
|
|
|
.hint-item + .hint-item {
|
|
|
margin-left: 4px;
|
|
|
}
|
|
|
|
|
|
.var-code {
|
|
|
color: #059669;
|
|
|
background: #ecfdf5;
|
|
|
padding: 1px 4px;
|
|
|
border-radius: 3px;
|
|
|
font-family: "SFMono-Regular", Consolas, monospace;
|
|
|
font-size: 12px;
|
|
|
}
|
|
|
|
|
|
.kb-code {
|
|
|
color: #2563eb;
|
|
|
background: #eff6ff;
|
|
|
padding: 1px 4px;
|
|
|
border-radius: 3px;
|
|
|
font-family: "SFMono-Regular", Consolas, monospace;
|
|
|
font-size: 12px;
|
|
|
}
|
|
|
|
|
|
/* 代码编辑器样式 */
|
|
|
.code-editor {
|
|
|
flex: 1;
|
|
|
display: flex;
|
|
|
border: 1px solid #dcdfe6;
|
|
|
border-radius: 0 0 6px 6px;
|
|
|
background: #fff;
|
|
|
overflow: hidden;
|
|
|
position: relative;
|
|
|
|
|
|
&:focus-within {
|
|
|
border-color: #409eff;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.line-numbers {
|
|
|
flex-shrink: 0;
|
|
|
width: 40px;
|
|
|
background: #fafafa;
|
|
|
border-right: 1px solid #e5e7eb;
|
|
|
padding: 8px 0;
|
|
|
text-align: right;
|
|
|
font-family: "SFMono-Regular", Consolas, monospace;
|
|
|
font-size: 12px;
|
|
|
color: #c0c4cc;
|
|
|
line-height: 20px;
|
|
|
overflow: hidden;
|
|
|
user-select: none;
|
|
|
}
|
|
|
|
|
|
.line-num {
|
|
|
display: block;
|
|
|
padding-right: 8px;
|
|
|
}
|
|
|
|
|
|
.code-textarea {
|
|
|
flex: 1;
|
|
|
border: none;
|
|
|
outline: none;
|
|
|
resize: none;
|
|
|
padding: 8px 12px;
|
|
|
font-family: "SFMono-Regular", Consolas, "Courier New", monospace;
|
|
|
font-size: 13px;
|
|
|
line-height: 20px;
|
|
|
color: #1f2937;
|
|
|
background: #fff;
|
|
|
min-height: 400px;
|
|
|
tab-size: 2;
|
|
|
white-space: pre;
|
|
|
overflow-wrap: normal;
|
|
|
overflow: auto;
|
|
|
|
|
|
&::placeholder {
|
|
|
color: #c0c4cc;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.editor-footer {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
align-items: center;
|
|
|
padding: 6px 2px;
|
|
|
font-size: 12px;
|
|
|
color: #9ca3af;
|
|
|
}
|
|
|
|
|
|
.footer-actions {
|
|
|
display: flex;
|
|
|
justify-content: flex-end;
|
|
|
gap: 8px;
|
|
|
padding-top: 4px;
|
|
|
}
|
|
|
</style>
|