feat: 添加规则管理模块及api配置

master
4 days ago
parent 0783282b3c
commit b8387f6899

@ -0,0 +1,285 @@
// 规则管理模块接口
// 包含:规则分类(/rule/category、规则列表/rule
import { request } from "@utils";
const RULE_CATEGORY_PATH = "/rule/category";
const RULE_PATH = "/rule";
// ============ 规则分类 ============
const RuleCategoryAPI = {
/** 查询规则分类分页列表 */
page(query?: RuleCategoryPageQuery) {
return request<ApiResponse<PageResult<RuleCategoryItem>>>({
url: `${RULE_CATEGORY_PATH}/list`,
method: "get",
params: query,
});
},
/** 查询规则分类树 */
tree(query?: RuleCategoryTreeQuery) {
return request<ApiResponse<RuleCategoryNode[]>>({
url: `${RULE_CATEGORY_PATH}/tree`,
method: "get",
params: query,
});
},
/** 查询规则分类详情 */
detail(id: number) {
return request<ApiResponse<RuleCategoryItem>>({
url: `${RULE_CATEGORY_PATH}/detail/${id}`,
method: "get",
});
},
/** 创建规则分类 */
create(body: RuleCategoryForm) {
return request<ApiResponse<RuleCategoryItem>>({
url: `${RULE_CATEGORY_PATH}/create`,
method: "post",
data: body,
});
},
/** 修改规则分类 */
update(id: number, body: RuleCategoryForm) {
return request<ApiResponse<RuleCategoryItem>>({
url: `${RULE_CATEGORY_PATH}/update/${id}`,
method: "put",
data: body,
});
},
/** 删除规则分类(传 ID 数组) */
remove(ids: number[]) {
return request<ApiResponse>({
url: `${RULE_CATEGORY_PATH}/delete`,
method: "delete",
data: ids,
});
},
};
// ============ 规则 ============
const RuleAPI = {
/** 查询规则分页列表 */
page(query: RulePageQuery) {
return request<ApiResponse<PageResult<RuleItem>>>({
url: `${RULE_PATH}/list`,
method: "get",
params: query,
});
},
/** 查询规则详情 */
detail(id: number) {
return request<ApiResponse<RuleItem>>({
url: `${RULE_PATH}/detail/${id}`,
method: "get",
});
},
/** 创建规则 */
create(body: RuleForm) {
return request<ApiResponse<RuleItem>>({
url: `${RULE_PATH}/create`,
method: "post",
data: body,
});
},
/** 修改规则 */
update(id: number, body: RuleForm) {
return request<ApiResponse<RuleItem>>({
url: `${RULE_PATH}/update/${id}`,
method: "put",
data: body,
});
},
/** 删除规则(传 ID 数组,软删除) */
remove(ids: number[]) {
return request<ApiResponse>({
url: `${RULE_PATH}/delete`,
method: "delete",
data: ids,
});
},
// ---- 以下接口后端尚未提供,属于预留入口 ----
// 接口上线后若 URL / 入参不一致,仅需在此处调整即可。
/** 复制规则(预留) */
copy(id: number) {
return request<ApiResponse>({
url: `${RULE_PATH}/copy/${id}`,
method: "post",
});
},
/** 批量修改状态(预留) */
batchStatus(ids: number[], status: RuleStatus) {
return request<ApiResponse>({
url: `${RULE_PATH}/status/batch`,
method: "patch",
data: { ids, status },
});
},
/** 测试规则(预留) */
test(body: RuleTestRequest) {
return request<ApiResponse<RuleTestResult>>({
url: `${RULE_PATH}/test`,
method: "post",
data: body,
});
},
/** 测试用文件上传(预留) */
uploadTestFile(file: File) {
const formData = new FormData();
formData.append("file", file);
return request<ApiResponse<UploadFilePath>>({
url: `${RULE_PATH}/test/upload`,
method: "post",
data: formData,
headers: { "Content-Type": "multipart/form-data" },
});
},
};
export { RuleCategoryAPI, RuleAPI };
export default { RuleCategoryAPI, RuleAPI };
// ============ 类型定义 ============
/** 规则状态0 启用 / 1 停用 */
export type RuleStatus = 0 | 1;
/** 规则分类树查询参数 */
export interface RuleCategoryTreeQuery {
name?: string;
parent_id?: number;
status?: RuleStatus;
created_time?: string[];
updated_time?: string[];
created_id?: number;
updated_id?: number;
tenant_id?: number;
}
/** 规则分类分页查询参数 */
export interface RuleCategoryPageQuery extends RuleCategoryTreeQuery {
page_no: number;
page_size: number;
order_by?: string;
}
/** 规则分类对象(后端返回) */
export interface RuleCategoryItem {
id: number;
uuid?: string;
name: string;
parent_id: number | null;
parent_name?: string;
order?: number;
status: RuleStatus;
description?: string;
tenant_id?: number;
created_id?: number;
created_by?: CommonType;
updated_id?: number;
updated_by?: CommonType;
created_time?: string;
updated_time?: string;
is_deleted?: boolean;
}
/** 规则分类树节点 */
export interface RuleCategoryNode extends RuleCategoryItem {
children?: RuleCategoryNode[];
}
/** 规则分类表单(新增/编辑) */
export interface RuleCategoryForm {
id?: number;
name: string;
parent_id: number | null;
order?: number;
status?: RuleStatus;
description?: string;
}
/** 规则分页查询参数 */
export interface RulePageQuery {
page_no: number;
page_size: number;
order_by?: string;
name?: string;
category_id?: number;
status?: RuleStatus;
created_time?: string[];
updated_time?: string[];
created_id?: number;
updated_id?: number;
tenant_id?: number;
}
/** 规则对象(后端返回) */
export interface RuleItem {
id: number;
uuid?: string;
name: string;
category_id: number | null;
/** 分类名称快照(创建/修改时保存) */
category_name_snapshot?: string;
/** 当前返回值等于 category_name_snapshot */
category_name?: string;
description?: string;
prompt_template: string;
status: RuleStatus;
tenant_id?: number;
created_id?: number;
created_by?: CommonType;
updated_id?: number;
updated_by?: CommonType;
created_time?: string;
updated_time?: string;
is_deleted?: boolean;
}
/** 规则表单(新建/编辑) */
export interface RuleForm {
id?: number;
name: string;
category_id: number | null;
description?: string;
prompt_template: string;
status: RuleStatus;
}
/** 规则测试请求 */
export interface RuleTestRequest {
rule_id: number;
content?: string;
file_id?: string;
}
/** 规则测试响应 */
export interface RuleTestResult {
output?: string;
elapsed?: number;
tokens?: number;
raw?: any;
}
/** 提示词模板中 @ 引用项 */
export interface PromptMentionRef {
knowledge_id: string;
knowledge_name: string;
document_id: string;
document_name: string;
}

@ -85,7 +85,7 @@ export const SETTING_DEFAULT_CONFIG = {
/** 菜单类型 */
menuType: MenuTypeEnum.LEFT,
/** 菜单展开宽度 */
menuOpenWidth: 230,
menuOpenWidth: 280,
/** 菜单是否展开 */
menuOpen: true,
/** 双菜单是否显示文本 */

@ -0,0 +1,462 @@
<!-- 规则分类 树状列表 -->
<template>
<div class="rule-category-page">
<!-- 顶部工具栏 -->
<div class="page-header">
<span class="page-title">规则分类管理</span>
<ElButton type="primary" :icon="Plus" @click="handleAddTop"></ElButton>
</div>
<!-- 树状列表 -->
<div class="tree-card">
<div v-loading="loading" class="tree-wrapper">
<ElTree
ref="treeRef"
:data="treeData"
:props="treeProps"
node-key="id"
:default-expand-all="defaultExpandAll"
:filter-node-method="filterNode"
:expand-on-click-node="false"
highlight-current
>
<template #default="{ node, data }">
<div class="tree-node">
<div class="tree-node-label">
<span
:class="['expand-arrow', { expanded: node.expanded }]"
@click="() => node.expanded = !node.expanded"
>
<ElIcon v-if="node.childNodes && node.childNodes.length > 0">
<ArrowRight />
</ElIcon>
</span>
<span class="node-name" :title="node.label">{{ node.label }}</span>
<span v-if="data.description" class="node-desc" :title="data.description">
{{ data.description }}
</span>
</div>
<div class="tree-node-actions" @click.stop>
<ElButton link type="primary" :icon="Plus" @click="handleAddChild(data)">
新增子分类
</ElButton>
<ElButton link type="primary" :icon="Edit" @click="handleEdit(data)">
编辑
</ElButton>
<ElButton link type="danger" :icon="Delete" @click="handleDelete(data)">
删除
</ElButton>
</div>
</div>
</template>
</ElTree>
<ElEmpty v-if="!loading && treeData.length === 0" description="暂无分类数据" />
</div>
</div>
<!-- 新增/编辑弹窗 -->
<ElDialog
v-model="dialogVisible"
:title="dialogTitle"
width="520px"
destroy-on-close
>
<ElForm
ref="formRef"
:model="form"
:rules="formRules"
label-width="100px"
label-position="right"
>
<ElFormItem label="分类名称" prop="name" required>
<ElInput v-model="form.name" placeholder="请输入分类名称" :maxlength="64" />
</ElFormItem>
<ElFormItem v-if="showParentSelect" label="父级分类" prop="parent_id">
<ElTreeSelect
v-model="form.parent_id"
:data="parentTreeOptions"
:props="treeSelectProps"
node-key="id"
check-strictly
placeholder="请选择父级分类"
clearable
class="full-width"
/>
</ElFormItem>
<ElFormItem label="排序" prop="order">
<ElInputNumber v-model="form.order" :min="0" :max="9999" controls-position="right" />
</ElFormItem>
<!-- <ElFormItem label="状态" prop="status">
<ElRadioGroup v-model="form.status">
<ElRadio :value="0">启用</ElRadio>
<ElRadio :value="1">停用</ElRadio>
</ElRadioGroup>
</ElFormItem> -->
<ElFormItem label="描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="4"
placeholder="请输入描述"
:maxlength="500"
show-word-limit
/>
</ElFormItem>
<ElFormItem label="创建人" prop="creatorDisplay">
<ElInput v-model="creatorDisplay" disabled placeholder="创建人" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit"></ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "RuleCategory" });
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus, Edit, Delete, ArrowRight } from "@element-plus/icons-vue";
import { RuleCategoryAPI } from "@/api/module_rule/rule";
import type { RuleCategoryForm, RuleCategoryNode } from "@/api/module_rule/rule";
import { useUserStoreHook } from "@stores";
defineExpose({ loadTree });
const treeRef = ref();
const treeData = ref<RuleCategoryNode[]>([]);
const loading = ref(false);
const defaultExpandAll = ref(true);
const treeProps = {
label: "name",
children: "children",
};
const treeSelectProps = {
label: "name",
children: "children",
};
function filterNode(value: string, data: any) {
if (!value) return true;
return data?.name?.includes(value);
}
//
async function loadTree() {
loading.value = true;
try {
const resp = await RuleCategoryAPI.tree();
const list = (resp?.data?.data as RuleCategoryNode[]) || [];
treeData.value = Array.isArray(list) ? list : [];
} catch (e) {
treeData.value = [];
} finally {
loading.value = false;
}
}
// ============ ============
const userStore = useUserStoreHook();
const currentUser = computed(() => {
const info = userStore.info as any;
return info?.name || info?.username || "";
});
//
const creatorDisplay = ref("");
// ============ ============
const dialogVisible = ref(false);
const dialogMode = ref<"create" | "edit">("create");
const editingNode = ref<RuleCategoryNode | null>(null);
const submitting = ref(false);
const formRef = ref<FormInstance | null>(null);
const form = reactive<RuleCategoryForm>({
id: undefined,
name: "",
parent_id: null,
order: 999,
status: 0,
description: "",
});
const formRules: FormRules = {
name: [
{ required: true, message: "请输入分类名称", trigger: "blur" },
{ max: 64, message: "最多 64 个字符", trigger: "blur" },
],
};
const dialogTitle = computed(() =>
dialogMode.value === "edit" ? "编辑分类" : "新增分类"
);
//
// - parent_id=null
// -
// -
const showParentSelect = computed(() => dialogMode.value === "edit");
//
const parentTreeOptions = computed<RuleCategoryNode[]>(() => {
if (!editingNode.value) return treeData.value;
return excludeSubtree(treeData.value, editingNode.value.id);
});
function excludeSubtree(
nodes: RuleCategoryNode[],
excludeId: number
): RuleCategoryNode[] {
const walk = (list: RuleCategoryNode[]): RuleCategoryNode[] => {
const result: RuleCategoryNode[] = [];
for (const n of list) {
if (n.id === excludeId) continue;
const cloned: RuleCategoryNode = { ...n };
if (n.children && n.children.length > 0) {
cloned.children = walk(n.children);
} else {
delete cloned.children;
}
result.push(cloned);
}
return result;
};
return walk(nodes);
}
function resetForm() {
form.id = undefined;
form.name = "";
form.parent_id = null;
form.order = 999;
form.status = 0;
form.description = "";
formRef.value?.clearValidate();
}
function handleAddTop() {
dialogMode.value = "create";
editingNode.value = null;
resetForm();
form.parent_id = null;
creatorDisplay.value = currentUser.value;
dialogVisible.value = true;
}
function handleAddChild(node: RuleCategoryNode) {
dialogMode.value = "create";
editingNode.value = null;
resetForm();
form.parent_id = node.id;
creatorDisplay.value = currentUser.value;
dialogVisible.value = true;
}
function handleEdit(node: RuleCategoryNode) {
dialogMode.value = "edit";
editingNode.value = node;
resetForm();
form.id = node.id;
form.name = node.name;
form.parent_id = node.parent_id ?? null;
form.order = node.order ?? 999;
form.status = node.status ?? 0;
form.description = node.description ?? "";
//
creatorDisplay.value = node.created_by?.name ?? currentUser.value;
dialogVisible.value = true;
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
submitting.value = true;
try {
const payload: RuleCategoryForm = {
id: form.id,
name: form.name.trim(),
parent_id: form.parent_id ?? null,
order: form.order ?? 999,
status: form.status ?? 0,
description: form.description?.trim() ?? "",
};
if (dialogMode.value === "edit" && form.id) {
await RuleCategoryAPI.update(form.id, payload);
ElMessage.success("修改成功");
} else {
await RuleCategoryAPI.create(payload);
ElMessage.success("新增成功");
}
dialogVisible.value = false;
await loadTree();
} catch (e: any) {
} finally {
submitting.value = false;
}
}
function handleDelete(node: RuleCategoryNode) {
ElMessageBox.confirm(
`确定要删除分类「${node.name}」吗?`,
"删除确认",
{ type: "warning", confirmButtonText: "删除", cancelButtonText: "取消" }
)
.then(async () => {
try {
await RuleCategoryAPI.remove([node.id]);
ElMessage.success("删除成功");
await loadTree();
} catch (e: any) {
}
})
.catch(() => {});
}
onMounted(() => {
loadTree();
});
</script>
<style lang="scss" scoped>
.rule-category-page {
display: flex;
flex-direction: column;
height: 100%;
// padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fff;
border-radius: 8px 8px 0 0;
border: 1px solid #e5e7eb;
border-bottom: 1px solid #f3f4f6;
}
.page-title {
font-size: 15px;
font-weight: 600;
color: #111827;
}
.tree-card {
flex: 1;
min-height: 0;
background: #fff;
border: 1px solid #e5e7eb;
border-top: none;
border-radius: 0 0 8px 8px;
overflow: hidden;
}
.tree-wrapper {
height: 100%;
overflow: auto;
padding: 8px 16px;
}
:deep(.el-tree) {
background: transparent;
.el-tree-node__content {
height: 36px;
border-radius: 4px;
padding-right: 8px;
}
.el-tree-node__content:hover {
background: #f5f7fa;
}
.el-tree-node__expand-icon {
display: none;
}
}
.tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 8px;
min-width: 0;
}
.tree-node-label {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
flex: 1;
overflow: hidden;
}
.expand-arrow {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #9ca3af;
font-size: 12px;
transition: transform 0.2s;
flex-shrink: 0;
&.expanded {
transform: rotate(90deg);
}
}
.node-name {
font-size: 14px;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-desc {
font-size: 13px;
color: #9ca3af;
margin-left: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 480px;
}
.tree-node-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s ease;
flex-shrink: 0;
}
:deep(.el-tree-node__content:hover) .tree-node-actions {
opacity: 1;
}
.full-width {
width: 100%;
}
</style>

@ -0,0 +1,344 @@
<!-- 提示词模板 @ 引用选知识库 -> 选文档 -->
<template>
<ElDialog
v-model="visible"
title="插入知识库引用"
width="780px"
destroy-on-close
@open="handleOpen"
>
<div class="mention-dialog-body">
<!-- 左侧知识库列表 -->
<div class="pane knowledge-pane">
<div class="pane-header">
<span class="pane-title">知识库</span>
<ElInput
v-model="knowledgeKeyword"
placeholder="搜索知识库"
size="small"
clearable
class="pane-search"
/>
</div>
<div v-loading="knowledgeLoading" class="pane-list">
<div
v-for="item in filteredKnowledgeList"
:key="item.id"
class="list-item"
:class="{ active: selectedKnowledgeId === item.id }"
@click="handleSelectKnowledge(item)"
>
<ElIcon class="list-item-icon"><Reading /></ElIcon>
<div class="list-item-main">
<div class="list-item-name" :title="item.name">{{ item.name }}</div>
<div v-if="item.description" class="list-item-desc" :title="item.description">
{{ item.description }}
</div>
</div>
</div>
<ElEmpty v-if="!knowledgeLoading && filteredKnowledgeList.length === 0" :image-size="60" />
</div>
</div>
<!-- 右侧文档列表 -->
<div class="pane document-pane">
<div class="pane-header">
<span class="pane-title">文档</span>
<ElInput
v-if="selectedKnowledgeId"
v-model="documentKeyword"
placeholder="搜索文档"
size="small"
clearable
class="pane-search"
/>
</div>
<div v-loading="documentLoading" class="pane-list">
<template v-if="selectedKnowledgeId">
<div
v-for="doc in filteredDocumentList"
:key="doc.id"
class="list-item"
:class="{ active: selectedDocumentId === doc.id }"
@click="handleSelectDocument(doc)"
>
<ElIcon class="list-item-icon"><Document /></ElIcon>
<div class="list-item-main">
<div class="list-item-name" :title="doc.name">{{ doc.name }}</div>
<div class="list-item-meta">
<span v-if="doc.doc_form">{{ DOC_FORM_LABEL[doc.doc_form] || doc.doc_form }}</span>
<span v-if="doc.word_count != null"> · {{ doc.word_count }} </span>
</div>
</div>
</div>
<ElEmpty
v-if="!documentLoading && filteredDocumentList.length === 0"
:image-size="60"
/>
</template>
<div v-else class="pane-placeholder">
<ElIcon class="placeholder-icon"><Select /></ElIcon>
<span>请先在左侧选择知识库</span>
</div>
</div>
</div>
</div>
<template #footer>
<div class="footer-bar">
<div class="footer-preview">
<span v-if="selectedKnowledgeId && selectedDocumentId" class="preview-tag">
将插入<code>@{{ selectedKnowledgeName }}/{{ selectedDocumentName }}</code>
</span>
</div>
<div class="footer-actions">
<ElButton @click="visible = false">取消</ElButton>
<ElButton
type="primary"
:disabled="!selectedKnowledgeId || !selectedDocumentId"
@click="handleConfirm"
>
确定
</ElButton>
</div>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
defineOptions({ name: "PromptMentionDialog" });
import { ref, computed, watch } from "vue";
import { Reading, Document, Select } from "@element-plus/icons-vue";
import { usePromptMention } from "../usePromptMention";
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{
(e: "update:modelValue", val: boolean): void;
(e: "confirm", text: string): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val),
});
const {
knowledgeList,
knowledgeLoading,
documentList,
documentLoading,
loadKnowledgeList,
loadDocumentList,
} = usePromptMention();
const knowledgeKeyword = ref("");
const documentKeyword = ref("");
const selectedKnowledgeId = ref<string>("");
const selectedKnowledgeName = ref<string>("");
const selectedDocumentId = ref<string>("");
const selectedDocumentName = ref<string>("");
const DOC_FORM_LABEL: Record<string, string> = {
text_model: "文本",
qa_model: "Q&A",
hierarchical_model: "父子",
};
const filteredKnowledgeList = computed(() => {
const kw = knowledgeKeyword.value.trim().toLowerCase();
if (!kw) return knowledgeList.value;
return knowledgeList.value.filter((k) => k.name.toLowerCase().includes(kw));
});
const filteredDocumentList = computed(() => {
const kw = documentKeyword.value.trim().toLowerCase();
if (!kw) return documentList.value;
return documentList.value.filter((d) => d.name.toLowerCase().includes(kw));
});
function handleOpen() {
knowledgeKeyword.value = "";
documentKeyword.value = "";
selectedKnowledgeId.value = "";
selectedKnowledgeName.value = "";
selectedDocumentId.value = "";
selectedDocumentName.value = "";
documentList.value = [];
loadKnowledgeList();
}
async function handleSelectKnowledge(item: { id: string; name: string }) {
if (selectedKnowledgeId.value === item.id) return;
selectedKnowledgeId.value = item.id;
selectedKnowledgeName.value = item.name;
selectedDocumentId.value = "";
selectedDocumentName.value = "";
await loadDocumentList(item.id);
}
function handleSelectDocument(doc: { id: string; name: string }) {
selectedDocumentId.value = doc.id;
selectedDocumentName.value = doc.name;
}
function handleConfirm() {
if (!selectedKnowledgeId.value || !selectedDocumentId.value) return;
const text = `@${selectedKnowledgeName.value}/${selectedDocumentName.value}`;
emit("confirm", text);
visible.value = false;
}
//
watch(knowledgeKeyword, () => {
selectedKnowledgeId.value = "";
selectedKnowledgeName.value = "";
selectedDocumentId.value = "";
selectedDocumentName.value = "";
});
</script>
<style lang="scss" scoped>
.mention-dialog-body {
display: flex;
gap: 12px;
height: 420px;
}
.pane {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
background: #fff;
min-width: 0;
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid #f3f4f6;
background: #fafafa;
gap: 8px;
}
.pane-title {
font-size: 13px;
font-weight: 600;
color: #374151;
flex-shrink: 0;
}
.pane-search {
width: 180px;
}
.pane-list {
flex: 1;
overflow: auto;
padding: 6px;
}
.pane-placeholder {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #9ca3af;
font-size: 13px;
}
.placeholder-icon {
font-size: 32px;
color: #d1d5db;
}
.list-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease;
border: 1px solid transparent;
&:hover {
background: #f3f4f6;
}
&.active {
background: #eff6ff;
border-color: #bfdbfe;
}
}
.list-item-icon {
font-size: 16px;
color: #6b7280;
margin-top: 2px;
flex-shrink: 0;
}
.list-item-main {
flex: 1;
min-width: 0;
}
.list-item-name {
font-size: 13px;
color: #111827;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-item-desc {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-item-meta {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
.footer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.footer-preview {
flex: 1;
min-width: 0;
}
.preview-tag {
font-size: 12px;
color: #6b7280;
code {
color: #2563eb;
background: #eff6ff;
padding: 1px 6px;
border-radius: 4px;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
}
}
</style>

@ -0,0 +1,478 @@
<!-- 规则新建 / 编辑页 -->
<template>
<div class="rule-edit-page">
<div class="page-header">
<ElButton text :icon="ArrowLeft" @click="handleBack"></ElButton>
<span class="page-title">{{ isEdit ? "编辑规则" : "新建规则" }}</span>
</div>
<!-- 基础信息 -->
<ElCard class="form-card" shadow="never">
<template #header>
<span class="section-title">基础信息</span>
</template>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="110px"
label-position="right"
>
<ElRow :gutter="24">
<ElCol :span="12">
<ElFormItem label="规则名称" prop="name" required>
<ElInput
v-model="form.name"
placeholder="请输入规则名称"
:maxlength="50"
show-word-limit
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<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>
</ElCol>
</ElRow>
<ElFormItem label="规则描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
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" 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 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;
}
.page-title {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.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;
}
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #111827;
}
/* 提示词模板区域 */
.prompt-section {
display: flex;
flex-direction: column;
gap: 0;
}
.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 {
display: flex;
border: 1px solid #dcdfe6;
border-radius: 0 0 6px 6px;
background: #fff;
min-height: 360px;
max-height: 600px;
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: 360px;
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>

@ -0,0 +1,328 @@
<!-- 规则测试抽屉 -->
<template>
<ElDrawer
:model-value="modelValue"
title="规则测试"
direction="rtl"
size="60%"
:close-on-click-modal="false"
@update:model-value="(v) => emit('update:modelValue', v)"
@close="handleClose"
>
<div class="test-drawer" v-loading="loading">
<!-- 提示词内容只读展示 -->
<div class="section">
<div class="section-header">
<span class="section-title">提示词内容</span>
</div>
<div class="prompt-readonly">
<pre>{{ rule?.prompt_template || "无提示词模板" }}</pre>
</div>
</div>
<!-- 测试文档内容 -->
<div class="section">
<div class="section-header">
<span class="section-title">测试文档内容</span>
<ElRadioGroup v-model="inputMode" size="default" class="mode-switch">
<ElRadioButton value="text">文本输入</ElRadioButton>
<ElRadioButton value="file">文件上传</ElRadioButton>
</ElRadioGroup>
</div>
<!-- 文本输入 -->
<div v-show="inputMode === 'text'" class="input-area">
<ElInput
v-model="testContent"
type="textarea"
:rows="8"
placeholder="请粘贴待测试的文本内容"
resize="vertical"
/>
</div>
<!-- 文件上传 -->
<div v-show="inputMode === 'file'" class="file-area">
<ElUpload
ref="uploadRef"
:auto-upload="false"
:limit="1"
:file-list="fileList"
accept=".txt,.doc,.docx,.pdf,.md"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-exceed="handleExceed"
drag
>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">
支持 txt/doc/docx/pdf/md 格式单个文件不超过 20MB
</div>
</template>
</ElUpload>
</div>
</div>
<!-- 审查结果 -->
<div class="section">
<div class="section-header">
<span class="section-title">审查结果</span>
<ElButton
v-if="result"
text
type="info"
size="small"
:icon="Delete"
@click="handleClearResult"
>
清空结果
</ElButton>
</div>
<div v-if="result" class="result-area">
<div class="result-meta">
<ElTag v-if="result.elapsed != null" size="small" type="info">
耗时 {{ result.elapsed }}ms
</ElTag>
<ElTag v-if="result.tokens != null" size="small" type="info">
消耗 {{ result.tokens }} tokens
</ElTag>
</div>
<div class="result-content">
<pre>{{ result.output || "(审查结果为空)" }}</pre>
</div>
</div>
<ElEmpty v-else description="暂无审查结果,点击「开始测试」后将展示结果" />
</div>
</div>
<template #footer>
<div class="drawer-footer">
<ElButton @click="handleClose"></ElButton>
<ElButton type="primary" :loading="testing" @click="handleTest">
开始测试
</ElButton>
</div>
</template>
</ElDrawer>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { UploadFilled, Delete } from "@element-plus/icons-vue";
import type { UploadFile, UploadFiles, UploadInstance } from "element-plus";
import { RuleAPI } from "@/api/module_rule/rule";
import type { RuleItem, RuleTestResult } from "@/api/module_rule/rule";
interface Props {
modelValue: boolean;
rule?: RuleItem | null;
}
const props = withDefaults(defineProps<Props>(), {
rule: null,
});
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void;
}>();
const loading = ref(false);
const testing = ref(false);
const inputMode = ref<"text" | "file">("text");
const testContent = ref("");
const fileList = ref<UploadFile[]>([]);
const uploadRef = ref<UploadInstance | null>(null);
const result = ref<RuleTestResult | null>(null);
function handleFileChange(file: UploadFile, files: UploadFiles) {
fileList.value = files.slice(-1);
}
function handleFileRemove() {
fileList.value = [];
}
function handleExceed() {
ElMessage.warning("仅支持上传一个文件,新文件将替换旧文件");
}
function handleClearResult() {
result.value = null;
}
async function handleTest() {
if (!props.rule?.id) {
ElMessage.warning("缺少规则信息");
return;
}
let content: string | undefined;
let fileId: string | undefined;
if (inputMode.value === "text") {
content = testContent.value.trim();
if (!content) {
ElMessage.warning("请输入待测试的文本内容");
return;
}
} else {
if (fileList.value.length === 0) {
ElMessage.warning("请先选择要上传的文件");
return;
}
const f = fileList.value[0];
if (!f || !f.raw) {
ElMessage.warning("文件读取异常,请重新选择");
return;
}
try {
const uploadResp = await RuleAPI.uploadTestFile(f.raw);
const upData = (uploadResp?.data?.data as UploadFilePath | undefined)
|| (uploadResp?.data as unknown as UploadFilePath | undefined);
fileId = upData?.file_path || `local_${f.name}`;
if (!fileId) {
fileId = `local_${f.name}`;
}
} catch {
ElMessage.warning("文件上传接口暂不可用,将以文件名为标识进行本地模拟");
fileId = `local_${f.name}`;
}
}
testing.value = true;
result.value = null;
try {
const resp = await RuleAPI.test({
rule_id: props.rule.id,
content,
file_id: fileId,
});
const data = resp?.data?.data;
result.value = data ?? {
output: (resp?.data as any)?.output,
elapsed: (resp?.data as any)?.elapsed,
tokens: (resp?.data as any)?.tokens,
};
ElMessage.success("测试完成");
} catch (e: any) {
} finally {
testing.value = false;
}
}
function handleClose() {
emit("update:modelValue", false);
}
watch(
() => props.modelValue,
(v) => {
if (v) {
result.value = null;
}
}
);
</script>
<style lang="scss" scoped>
.test-drawer {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 16px 0;
padding-bottom: 64px;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.mode-switch {
:deep(.el-radio-button__inner) {
padding: 4px 12px;
font-size: 12px;
}
}
.prompt-readonly {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 12px 14px;
max-height: 220px;
overflow: auto;
pre {
margin: 0;
font-family: "SFMono-Regular", Consolas, "Courier New", monospace;
font-size: 12.5px;
line-height: 1.7;
color: #374151;
white-space: pre-wrap;
word-break: break-word;
}
}
.input-area :deep(.el-textarea__inner) {
font-family: "SFMono-Regular", Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.6;
}
.file-area :deep(.el-upload-dragger) {
padding: 24px 20px;
}
.result-area {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 6px;
padding: 12px 14px;
}
.result-meta {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.result-content {
background: #fff;
border-radius: 4px;
padding: 10px 12px;
border: 1px solid #e0f2fe;
pre {
margin: 0;
font-family: "SFMono-Regular", Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.7;
color: #0c4a6e;
white-space: pre-wrap;
word-break: break-word;
}
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

@ -0,0 +1,580 @@
<!-- 规则列表页 -->
<template>
<div class="rule-list-page">
<!-- 查询条件 -->
<div class="search-bar">
<div class="search-item">
<label class="search-label">规则名称</label>
<ElInput
v-model="query.name"
placeholder="请输入规则名称"
clearable
class="search-input"
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
</div>
<div class="search-item">
<label class="search-label">规则分类</label>
<ElTreeSelect
v-model="query.category_id"
:data="categoryTree"
:props="treeSelectProps"
node-key="id"
check-strictly
placeholder="请选择规则分类"
clearable
filterable
class="search-input"
/>
</div>
<div class="search-item">
<label class="search-label">状态</label>
<ElSelect
v-model="query.status"
placeholder="请选择状态"
clearable
class="search-input"
>
<ElOption label="启用" :value="0" />
<ElOption label="停用" :value="1" />
</ElSelect>
</div>
<div class="search-item date-item">
<label class="search-label">创建时间</label>
<ElDatePicker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
class="date-picker"
/>
</div>
<div class="search-actions">
<ElButton type="primary" :icon="Search" @click="handleSearch"></ElButton>
<ElButton :icon="Refresh" @click="handleReset"></ElButton>
</div>
</div>
<!-- 列表 -->
<div class="list-card">
<div class="list-header">
<div class="list-header-left">
<span class="list-title">规则列表</span>
<ElButton type="primary" :icon="Plus" class="create-btn" @click="handleCreate"></ElButton>
</div>
<div class="list-header-right">
<ElButton
type="success"
:disabled="selectedIds.length === 0"
@click="handleBatchEnable"
>
批量启用
</ElButton>
<ElButton
type="warning"
:disabled="selectedIds.length === 0"
@click="handleBatchDisable"
>
批量停用
</ElButton>
<ElButton
type="danger"
:disabled="selectedIds.length === 0"
@click="handleBatchDelete"
>
批量删除
</ElButton>
</div>
</div>
<ElTable
v-loading="loading"
:data="tableData"
row-key="id"
stripe
@selection-change="onSelectionChange"
>
<ElTableColumn type="selection" width="50" align="center" />
<ElTableColumn label="规则名称" prop="name" min-width="180">
<template #default="{ row }">
<span class="rule-name-link" @click="handleViewDetail(row)">{{ row.name }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="规则分类" prop="category_name" width="160" align="center">
<template #default="{ row }">
<span class="category-tag">{{ row.category_name || getFullLabel(row.category_id) || "-" }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="规则详情" width="100" align="center">
<template #default="{ row }">
<ElButton link type="primary" :icon="View" @click="handleViewDetail(row)"></ElButton>
</template>
</ElTableColumn>
<ElTableColumn label="创建人" width="100" align="center">
<template #default="{ row }">{{ row.created_by?.name || "-" }}</template>
</ElTableColumn>
<ElTableColumn label="创建时间" prop="created_time" width="170" align="center">
<template #default="{ row }">{{ formatDateTime(row.created_time) }}</template>
</ElTableColumn>
<ElTableColumn label="最近修改时间" prop="updated_time" width="170" align="center">
<template #default="{ row }">{{ formatDateTime(row.updated_time) }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
<template #default="{ row }">
<ElSwitch
v-model="row.status"
:active-value="0"
:inactive-value="1"
active-text="启用"
inactive-text="停用"
inline-prompt
@change="(val: string | number | boolean) => handleToggleStatus(row, val as RuleStatus)"
/>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<ElButton link type="primary" @click="handleEdit(row)"></ElButton>
<ElButton link type="primary" @click="handleTest(row)"></ElButton>
<ElButton link type="primary" @click="handleCopy(row)"></ElButton>
<ElButton link type="danger" @click="handleDelete(row)"></ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="暂无规则数据" />
</template>
</ElTable>
<div class="pagination">
<span class="total-text"> {{ total }} </span>
<ElPagination
:current-page="query.page_no"
:page-size="query.page_size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="sizes, prev, pager, next, jumper"
@current-change="onPageChange"
@size-change="onSizeChange"
/>
<span class="jump-text">前往</span>
<ElInputNumber
v-model="jumpPage"
:min="1"
:max="Math.max(1, Math.ceil(total / query.page_size))"
size="small"
:controls="false"
class="jump-input"
@keyup.enter="goPage"
/>
<span class="jump-text"></span>
</div>
</div>
<!-- 规则详情弹窗查看 -->
<ElDialog
v-model="detailVisible"
title="规则详情"
width="640px"
destroy-on-close
>
<el-descriptions :column="1" border v-if="detailRow">
<el-descriptions-item label="规则名称">{{ detailRow.name }}</el-descriptions-item>
<el-descriptions-item label="规则分类">
{{ detailRow.category_name || getFullLabel(detailRow.category_id) }}
</el-descriptions-item>
<el-descriptions-item label="创建人">{{ detailRow.created_by?.name || "-" }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(detailRow.created_time) }}</el-descriptions-item>
<el-descriptions-item label="最近修改时间">{{ formatDateTime(detailRow.updated_time) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detailRow.status === 0 ? "启用" : "停用" }}</el-descriptions-item>
<el-descriptions-item label="规则描述">{{ detailRow.description || "-" }}</el-descriptions-item>
<el-descriptions-item label="提示词模板">
<pre class="detail-prompt">{{ detailRow.prompt_template }}</pre>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<ElButton @click="detailVisible = false">关闭</ElButton>
<ElButton type="primary" @click="() => { detailVisible = false; handleEdit(detailRow!); }">编辑</ElButton>
</template>
</ElDialog>
<!-- 测试抽屉 -->
<RuleTestDrawer v-model="testDrawerVisible" :rule="currentTestRule" />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "RuleList" });
import { ref, reactive, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh, View } from "@element-plus/icons-vue";
import { RuleAPI } from "@/api/module_rule/rule";
import type { RuleItem, RulePageQuery, RuleStatus, RuleForm } from "@/api/module_rule/rule";
import { useRuleCategory } from "../useRuleCategory";
import RuleTestDrawer from "./RuleTestDrawer.vue";
const router = useRouter();
const { tree: categoryTree, loadTree, getFullLabel } = useRuleCategory();
const treeSelectProps = {
label: "name",
children: "children",
};
const loading = ref(false);
const tableData = ref<RuleItem[]>([]);
const total = ref(0);
const selectedIds = ref<number[]>([]);
const dateRange = ref<string[] | null>(null);
const jumpPage = ref(1);
const query = reactive<RulePageQuery>({
page_no: 1,
page_size: 10,
name: "",
category_id: undefined,
status: undefined,
created_time: undefined,
});
//
const detailVisible = ref(false);
const detailRow = ref<RuleItem | null>(null);
async function loadList() {
loading.value = true;
try {
const params: RulePageQuery = {
page_no: query.page_no,
page_size: query.page_size,
name: query.name?.trim() || undefined,
category_id: query.category_id,
status: query.status,
};
if (dateRange.value && dateRange.value.length === 2) {
params.created_time = dateRange.value;
}
const resp = await RuleAPI.page(params);
const data = resp?.data?.data as PageResult<RuleItem> | undefined;
tableData.value = data?.items ?? [];
total.value = data?.total ?? 0;
} catch (e) {
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
function handleSearch() {
query.page_no = 1;
loadList();
}
function handleReset() {
query.name = "";
query.category_id = undefined;
query.status = undefined;
dateRange.value = null;
query.page_no = 1;
loadList();
}
function onPageChange(page: number) {
query.page_no = page;
loadList();
}
function onSizeChange(size: number) {
query.page_size = size;
query.page_no = 1;
loadList();
}
function goPage() {
if (jumpPage.value && jumpPage.value >= 1) {
query.page_no = jumpPage.value;
loadList();
}
}
function onSelectionChange(rows: RuleItem[]) {
selectedIds.value = rows.map((r) => r.id);
}
function handleCreate() {
router.push("/rule/list/edit");
}
function handleEdit(row: RuleItem) {
router.push(`/rule/list/edit?id=${row.id}`);
}
function handleViewDetail(row: RuleItem) {
detailRow.value = row;
detailVisible.value = true;
}
//
const testDrawerVisible = ref(false);
const currentTestRule = ref<RuleItem | null>(null);
function handleTest(row: RuleItem) {
currentTestRule.value = row;
testDrawerVisible.value = true;
}
//
async function handleCopy(row: RuleItem) {
try {
await RuleAPI.copy(row.id);
ElMessage.success("复制成功");
loadList();
} catch (e) {
//
}
}
// update status
async function handleToggleStatus(row: RuleItem, val: RuleStatus) {
const actionText = val === 0 ? "启用" : "停用";
try {
const payload: RuleForm = {
id: row.id,
name: row.name,
category_id: row.category_id ?? null,
description: row.description ?? "",
prompt_template: row.prompt_template,
status: val,
};
await RuleAPI.update(row.id, payload);
ElMessage.success(`${actionText}成功`);
} catch (e) {
// UI
row.status = val === 0 ? 1 : 0;
}
}
//
function handleDelete(row: RuleItem) {
ElMessageBox.confirm(`确定要删除规则「${row.name}」吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
})
.then(async () => {
try {
await RuleAPI.remove([row.id]);
ElMessage.success("删除成功");
loadList();
} catch (e) {
//
}
})
.catch(() => {});
}
//
async function handleBatchEnable() {
if (selectedIds.value.length === 0) return;
try {
await RuleAPI.batchStatus(selectedIds.value, 0);
ElMessage.success("批量启用成功");
selectedIds.value = [];
loadList();
} catch (e) {}
}
//
async function handleBatchDisable() {
if (selectedIds.value.length === 0) return;
try {
await RuleAPI.batchStatus(selectedIds.value, 1);
ElMessage.success("批量停用成功");
selectedIds.value = [];
loadList();
} catch (e) {}
}
//
function handleBatchDelete() {
if (selectedIds.value.length === 0) return;
ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 条规则吗?`, "批量删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
})
.then(async () => {
try {
await RuleAPI.remove(selectedIds.value);
ElMessage.success("批量删除成功");
selectedIds.value = [];
loadList();
} catch (e) {}
})
.catch(() => {});
}
function formatDateTime(value?: string): string {
if (!value) return "-";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
d.getHours()
)}:${pad(d.getMinutes())}`;
}
onMounted(async () => {
await loadTree();
await loadList();
});
</script>
<style lang="scss" scoped>
.rule-list-page {
display: flex;
flex-direction: column;
height: 100%;
// padding: 16px;
gap: 12px;
}
/* 查询条件 */
.search-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
padding: 16px;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.search-item {
display: flex;
align-items: center;
gap: 8px;
}
.search-label {
font-size: 13px;
color: #374151;
white-space: nowrap;
min-width: 56px;
text-align: right;
}
.search-input {
width: 200px;
}
.date-picker {
width: 280px;
}
.search-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
/* 列表卡片 */
.list-card {
flex: 1;
min-height: 0;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f3f4f6;
flex-wrap: wrap;
gap: 8px;
}
.list-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.list-title {
font-size: 15px;
font-weight: 600;
color: #111827;
}
.create-btn {
margin-left: 4px;
}
.list-header-right {
display: flex;
gap: 8px;
}
.rule-name-link {
color: #409eff;
cursor: pointer;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
.category-tag {
display: inline-block;
padding: 1px 8px;
border: 1px solid #d0e3ff;
border-radius: 4px;
color: #409eff;
font-size: 12px;
background: #ecf5ff;
}
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
}
.total-text,
.jump-text {
font-size: 13px;
color: #6b7280;
}
.jump-input {
width: 60px;
}
.detail-prompt {
white-space: pre-wrap;
word-break: break-word;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
line-height: 1.6;
margin: 0;
padding: 8px;
background: #f9fafb;
border-radius: 4px;
max-height: 300px;
overflow: auto;
}
</style>

@ -0,0 +1,168 @@
// 提示词模板 @ 引用:知识库 -> 文档 选择
//
// 复用 `module_ai_knowledge` 模块已实现的 Dify 接口封装useDifyApis + api.ts
// 不重新封装一套 axios。Dify 接口需要先登录拿 csrf_token
// 本 composable 在加载知识库列表前会自动执行 login与 module_ai_knowledge 一致)。
import { ref } from "vue";
import { useDifyPost, useDifyGet, useRequest } from "@/views/module_ai_knowledge/useDifyApis";
import { knowledgeBase } from "@/views/module_ai_knowledge/api";
import { getCookie, encryptPassword } from "@/views/module_ai_knowledge/types";
export interface KnowledgeOption {
id: string;
name: string;
description?: string;
}
export interface DocumentOption {
id: string;
name: string;
doc_form?: string;
word_count?: number;
display_status?: string;
}
/** Dify 登录态本地缓存 key 与有效期1 小时) */
const DIFY_TIMEOUT_KEY = "currentDifyTimeOut";
const DIFY_EXPIRE_TIME = 60 * 60 * 1000;
export function usePromptMention() {
const { doRequest } = useRequest();
// 登录
const { doPost: loginDo } = useDifyPost({
url: knowledgeBase.login,
data: {
email: "gdut@gdut.com",
password: encryptPassword("gdut1234"),
language: "zh-Hans",
remember_me: true,
},
});
// 知识库列表
const { doGet: fetchKnowledgeList, loading: knowledgeLoading } = useDifyGet({
url: knowledgeBase.pageList,
params: { page: 1, limit: 100 },
});
const knowledgeList = ref<KnowledgeOption[]>([]);
const documentList = ref<DocumentOption[]>([]);
const documentLoading = ref(false);
/** 刷新 Dify 登录过期时间 */
function refreshExpireTime() {
localStorage.setItem(DIFY_TIMEOUT_KEY, String(Date.now() + DIFY_EXPIRE_TIME));
}
/** 判断是否需要重新登录 */
function needLogin(): boolean {
const csrfToken = getCookie("csrf_token");
const timeout = Number(localStorage.getItem(DIFY_TIMEOUT_KEY));
return !csrfToken || !timeout || Number.isNaN(timeout) || Date.now() >= timeout;
}
/** 确保 Dify 已登录,登录成功后返回 true */
async function ensureLogin(): Promise<boolean> {
if (!needLogin()) return true;
try {
const res: any = await loginDo();
if (res?.result === "success") {
refreshExpireTime();
return true;
}
return false;
} catch (e) {
console.error("Dify登录失败", e);
return false;
}
}
async function loadKnowledgeList() {
// 先确保 Dify 已登录
const ok = await ensureLogin();
if (!ok) {
knowledgeList.value = [];
return;
}
try {
const resp: any = await fetchKnowledgeList({
params: { page: 1, limit: 100 },
});
const list = Array.isArray(resp?.data) ? resp.data : [];
knowledgeList.value = list
.filter((item: any) => item && item.id && item.name)
.map((item: any) => ({
id: String(item.id),
name: String(item.name),
description:
typeof item.description === "string" ? item.description : "",
}));
} catch (e) {
// 登录态可能已失效,尝试重新登录后重试一次
try {
const res: any = await loginDo();
if (res?.result === "success") {
refreshExpireTime();
const retryResp: any = await fetchKnowledgeList({
params: { page: 1, limit: 100 },
});
const list = Array.isArray(retryResp?.data) ? retryResp.data : [];
knowledgeList.value = list
.filter((item: any) => item && item.id && item.name)
.map((item: any) => ({
id: String(item.id),
name: String(item.name),
description:
typeof item.description === "string" ? item.description : "",
}));
return;
}
} catch (e2) {
console.error("重新登录后加载知识库列表仍失败", e2);
}
knowledgeList.value = [];
}
}
async function loadDocumentList(knowledgeId: string) {
documentLoading.value = true;
documentList.value = [];
try {
const resp: any = await doRequest({
url: knowledgeBase.documents(knowledgeId),
method: "get",
params: { limit: 100, page: 1 },
});
// Dify 文档列表结构: { data: [...], has_more, total }
const rawList = Array.isArray(resp?.data)
? resp.data
: Array.isArray((resp as any)?.data?.data)
? (resp as any).data.data
: [];
documentList.value = rawList
.filter((item: any) => item && item.id && item.name)
.map((item: any) => ({
id: String(item.id),
name: String(item.name),
doc_form: item.doc_form,
word_count: item.word_count,
display_status: item.display_status,
}));
} catch (e) {
console.error("加载文档列表失败", e);
documentList.value = [];
} finally {
documentLoading.value = false;
}
}
return {
knowledgeList,
knowledgeLoading,
documentList,
documentLoading,
loadKnowledgeList,
loadDocumentList,
};
}

@ -0,0 +1,81 @@
// 规则分类共享 composable拉取树 + 扁平化为下拉选项
import { ref } from "vue";
import { RuleCategoryAPI } from "@/api/module_rule/rule";
import type { RuleCategoryNode } from "@/api/module_rule/rule";
export interface CategoryOption {
value: number;
label: string;
/** 完整路径名,例如 "父分类 / 子分类" */
fullLabel: string;
parent_id: number | null;
}
export function useRuleCategory() {
const tree = ref<RuleCategoryNode[]>([]);
const flatOptions = ref<CategoryOption[]>([]);
const loading = ref(false);
/** 按 order 字段递归排序order 小的在前,未设置默认 999 */
function sortByOrder(nodes: RuleCategoryNode[]): RuleCategoryNode[] {
const sorted = [...nodes].sort(
(a, b) => (a.order ?? 999) - (b.order ?? 999)
);
for (const n of sorted) {
if (n.children && n.children.length > 0) {
n.children = sortByOrder(n.children);
}
}
return sorted;
}
async function loadTree() {
loading.value = true;
try {
const resp = await RuleCategoryAPI.tree();
const list = (resp?.data?.data as RuleCategoryNode[]) || [];
tree.value = Array.isArray(list) ? sortByOrder(list) : [];
flatOptions.value = flatten(tree.value);
} catch (e) {
tree.value = [];
flatOptions.value = [];
} finally {
loading.value = false;
}
return tree.value;
}
function flatten(list: RuleCategoryNode[]): CategoryOption[] {
const result: CategoryOption[] = [];
const walk = (nodes: RuleCategoryNode[], parentPath: string) => {
for (const n of nodes) {
const fullLabel = parentPath ? `${parentPath} / ${n.name}` : n.name;
result.push({
value: n.id,
label: n.name,
fullLabel,
parent_id: n.parent_id ?? null,
});
if (n.children && n.children.length > 0) {
walk(n.children, fullLabel);
}
}
};
walk(list, "");
return result;
}
/** 根据 id 获取完整路径名 */
function getFullLabel(id: number | undefined | null): string {
if (id == null) return "";
return flatOptions.value.find((o) => o.value === id)?.fullLabel ?? "";
}
return {
tree,
flatOptions,
loading,
loadTree,
getFullLabel,
};
}
Loading…
Cancel
Save