You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

500 lines
12 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!-- 规则新建 / 编辑页 -->
<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>