feat(admin): Flowcharts.vue JSON 在线编辑 + 9 套排查模板种子数据

为管理后台'排查流程图'模块加 JSON 在线编辑能力 + 提供 9 套
办公 IT 常见故障排查模板种子数据(账号/系统/企微/VPN/邮箱/网络/
打印机/软件/硬件),管理员可基于此学习、筛选、修改、新增。

## 选型(按'优选开源'原则)
- @codemirror/lang-json / state / theme-one-dark / view
- codemirror(核心)
- vue-codemirror(Vue 3 集成)
- vue-json-pretty(JSON 树形预览)
全部为社区成熟开源组件,非自行开发

## 改动
- frontend-admin/package.json: 加 6 个 npm 依赖
- frontend-admin/src/api/troubleshooting.ts(新): TS 类型 +
  5 个 API client(listTemplates / getTemplate / createTemplate /
  updateTemplate / deleteTemplate) + formatJson/validateJson/
  countNodes/countDecisions 工具函数
- frontend-admin/src/components/flowchart/FlowchartEditorDialog.vue(新):
  双面板编辑器(左 CodeMirror + 右 vue-json-pretty),
  实时 JSON 校验 + 节点/决策统计 + 格式/复制/导出按钮
- frontend-admin/src/views/Flowcharts.vue(改): 列表 + 导入/导出/
  新建按钮 + EditorDialog 集成 + 文件上传 + 删除确认

## 9 套种子数据
- 01-account-password.json 账号密码
- 02-pc-system.json        电脑系统
- 03-wecom.json            企微问题
- 04-vpn.json              VPN 接入
- 05-email.json            邮箱
- 06-network.json          网络
- 07-printer.json          打印机
- 08-software.json         软件
- 09-hardware.json         硬件
每套 ~150-200 行,结构:name / category / description /
estimated_time / difficulty / tags / root_node(决策树)

## 工具脚本
- data/seed-templates/build_all.py: 合并 9 个 JSON 成 00-all.json
This commit is contained in:
Simon
2026-06-16 14:30:09 +08:00
parent caf9b7ed85
commit cec5607c45
14 changed files with 1824 additions and 156 deletions
+7
View File
@@ -13,11 +13,18 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/state": "^6.4.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.26.3",
"@element-plus/icons-vue": "^2.3.0",
"axios": "^1.7.0",
"codemirror": "^6.0.1",
"element-plus": "^2.7.0",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-codemirror": "^6.0.1",
"vue-json-pretty": "^2.2.4",
"vue-router": "^4.3.0"
},
"devDependencies": {
+171
View File
@@ -0,0 +1,171 @@
// =============================================================================
// 排查模板 API 客户端
// =============================================================================
// 对接后端 /api/troubleshooting-templates 5 个 REST 端点
// 5 个端点:GET 列表 / GET 详情 / POST 新建 / PUT 更新 / DELETE 删除
// =============================================================================
import axios from 'axios'
// -----------------------------------------------------------------------------
// 类型定义
// -----------------------------------------------------------------------------
/** 步骤节点(顺序执行) */
export interface PathStepNode {
id: string
type: 'step'
label: string
status?: 'done' | 'current' | 'pending'
children?: FlowchartNode[]
}
/** 决策节点(yes/no 分支) */
export interface DecisionNode {
id: string
type: 'decision'
label: string
status?: 'done' | 'current' | 'pending'
yes_branch?: FlowchartNode
no_branch?: FlowchartNode
children?: FlowchartNode[]
}
/** 流程图节点(递归) */
export type FlowchartNode = PathStepNode | DecisionNode
/** 排查模板 */
export interface TroubleshootingTemplate {
id?: string
name: string
category: string
description?: string
estimated_time?: number
difficulty?: number
tags?: string[]
root_node: FlowchartNode
version?: string
status?: 'draft' | 'published'
created_at?: string
updated_at?: string
// 后端可能附加的统计字段
nodeCount?: number
}
/** API 响应通用结构 */
interface ApiResponse<T> {
code: number
message: string
data: T
}
// -----------------------------------------------------------------------------
// Axios 实例(继承全局 baseURL)
// -----------------------------------------------------------------------------
const http = axios.create({
baseURL: '/api',
timeout: 30000,
})
// -----------------------------------------------------------------------------
// 5 个端点
// -----------------------------------------------------------------------------
/** GET /api/troubleshooting-templates — 获取模板列表 */
export async function listTemplates(): Promise<TroubleshootingTemplate[]> {
const res = await http.get<ApiResponse<TroubleshootingTemplate[]>>(
'/troubleshooting-templates'
)
return res.data.data || []
}
/** GET /api/troubleshooting-templates/{id} — 获取模板详情 */
export async function getTemplate(id: string): Promise<TroubleshootingTemplate> {
const res = await http.get<ApiResponse<TroubleshootingTemplate>>(
`/troubleshooting-templates/${id}`
)
return res.data.data
}
/** POST /api/troubleshooting-templates — 新建模板 */
export async function createTemplate(
data: TroubleshootingTemplate
): Promise<TroubleshootingTemplate> {
const res = await http.post<ApiResponse<TroubleshootingTemplate>>(
'/troubleshooting-templates',
data
)
return res.data.data
}
/** PUT /api/troubleshooting-templates/{id} — 更新模板 */
export async function updateTemplate(
id: string,
data: TroubleshootingTemplate
): Promise<TroubleshootingTemplate> {
const res = await http.put<ApiResponse<TroubleshootingTemplate>>(
`/troubleshooting-templates/${id}`,
data
)
return res.data.data
}
/** DELETE /api/troubleshooting-templates/{id} — 删除模板 */
export async function deleteTemplate(id: string): Promise<void> {
await http.delete(`/troubleshooting-templates/${id}`)
}
/** 工具:把对象格式化成 JSON 字符串(带缩进) */
export function formatJson(obj: unknown): string {
return JSON.stringify(obj, null, 2)
}
/** 工具:校验 JSON 字符串是否合法,返回 {ok, data, error} */
export function validateJson(
text: string
): { ok: true; data: TroubleshootingTemplate } | { ok: false; error: string } {
try {
const data = JSON.parse(text) as TroubleshootingTemplate
return { ok: true, data }
} catch (e) {
const err = e as Error
return { ok: false, error: err.message }
}
}
/** 工具:统计节点数(递归) */
export function countNodes(node: FlowchartNode | undefined): number {
if (!node) return 0
let count = 1
if (node.children) {
for (const child of node.children) {
count += countNodes(child)
}
}
// 决策节点的 yes/no 分支
if ('yes_branch' in node && node.yes_branch) {
count += countNodes(node.yes_branch)
}
if ('no_branch' in node && node.no_branch) {
count += countNodes(node.no_branch)
}
return count
}
/** 工具:统计决策节点数 */
export function countDecisions(node: FlowchartNode | undefined): number {
if (!node) return 0
let count = node.type === 'decision' ? 1 : 0
if (node.children) {
for (const child of node.children) {
count += countDecisions(child)
}
}
if ('yes_branch' in node && node.yes_branch) {
count += countDecisions(node.yes_branch)
}
if ('no_branch' in node && node.no_branch) {
count += countDecisions(node.no_branch)
}
return count
}
@@ -0,0 +1,475 @@
<!-- =============================================================================
// 排查流程图 — 在线编辑器对话框
// =============================================================================
// 双栏布局(用户已确认 A 方案):
// - 左 50%:CodeMirror JSON 源码(语法高亮 + 行号 + oneDark 主题)
// - 右 50%:vue-json-pretty 树形预览(只读)
// - 顶部:基本信息(名称/分类/标签/时间/难度)
// - 底栏:格式化/复制/导出/取消/保存
// ============================================================================= -->
<template>
<el-dialog
:model-value="modelValue"
@update:model-value="(v) => $emit('update:modelValue', v)"
:title="dialogTitle"
width="80%"
top="5vh"
:close-on-click-modal="false"
:destroy-on-close="true"
>
<!-- ===== 顶部基本信息 ===== -->
<div class="basic-info">
<el-form :inline="true" size="small" label-width="70px">
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="VPN 远程办公故障排查" style="width: 240px" :disabled="readonly" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.category" placeholder="选择分类" style="width: 130px" :disabled="readonly">
<el-option v-for="c in CATEGORY_OPTIONS" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="预估时间">
<el-input-number v-model="form.estimated_time" :min="1" :max="120" size="small" :disabled="readonly" />
<span class="suffix">分钟</span>
</el-form-item>
<el-form-item label="难度">
<el-rate v-model="form.difficulty" :max="5" :disabled="readonly" />
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="form.tags"
multiple
filterable
allow-create
default-first-option
placeholder=" Enter 添加"
style="width: 240px"
:disabled="readonly"
/>
</el-form-item>
</el-form>
</div>
<!-- ===== 双栏编辑区 ===== -->
<div class="dual-pane">
<!-- 左:JSON 源码编辑器 -->
<div class="pane left-pane">
<div class="pane-header">
<span class="pane-title">📝 JSON 源码</span>
<span class="pane-stats">
节点 {{ stats.nodes }} · 决策 {{ stats.decisions }}
</span>
</div>
<div class="pane-body">
<codemirror
v-model="jsonText"
:options="cmOptions"
:height="`${editorHeight}px`"
:style="{ height: `${editorHeight}px` }"
@change="onCodeChange"
:readonly="readonly"
/>
</div>
</div>
<!-- 右:树形预览 -->
<div class="pane right-pane">
<div class="pane-header">
<span class="pane-title">🌳 树形预览</span>
<span class="pane-stats">
<el-tag v-if="parseOk" type="success" size="small">✅ JSON 有效</el-tag>
<el-tag v-else type="danger" size="small">❌ {{ parseError }}</el-tag>
</span>
</div>
<div class="pane-body">
<div v-if="parseOk" class="tree-wrapper">
<vue-json-pretty
:data="parsedData"
:show-length="true"
:show-line="true"
:path="rootPath"
:deep="6"
/>
</div>
<div v-else class="tree-error">
<p>❌ JSON 解析失败</p>
<pre>{{ parseError }}</pre>
<p class="hint">请检查左栏 JSON 语法,例如:</p>
<ul>
<li>末尾的逗号</li>
<li>未闭合的引号或大括号</li>
<li>未转义的字符</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ===== 底栏按钮 ===== -->
<template #footer>
<div class="dialog-footer">
<div class="footer-left">
<el-button size="small" @click="handleFormat" :disabled="readonly">
<el-icon><MagicStick /></el-icon>
格式化
</el-button>
<el-button size="small" @click="handleCopy" :disabled="!parseOk">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
<el-button size="small" @click="handleExport" :disabled="!parseOk">
<el-icon><Download /></el-icon>
导出此条
</el-button>
</div>
<div class="footer-right">
<el-button size="small" @click="handleCancel">取消</el-button>
<el-button
size="small"
type="primary"
:disabled="!parseOk || readonly"
:loading="saving"
@click="handleSave"
>
<el-icon><Check /></el-icon>
保存
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
// =============================================================================
// imports
// =============================================================================
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { MagicStick, CopyDocument, Download, Check } from '@element-plus/icons-vue'
import { Codemirror } from 'vue-codemirror'
import { json } from '@codemirror/lang-json'
import { oneDark } from '@codemirror/theme-one-dark'
import VueJsonPretty from 'vue-json-pretty'
import 'vue-json-pretty/lib/styles.css'
import {
formatJson,
validateJson,
countNodes,
countDecisions,
type TroubleshootingTemplate,
type FlowchartNode,
} from '@/api/troubleshooting'
// =============================================================================
// props & emits
// =============================================================================
const props = defineProps<{
modelValue: boolean
template: TroubleshootingTemplate | null
readonly?: boolean
saving?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: [template: TroubleshootingTemplate]
cancel: []
}>()
// =============================================================================
// 常量
// =============================================================================
const CATEGORY_OPTIONS = [
'vpn', 'email', 'account', 'system', 'network',
'printer', 'software', 'hardware', 'wecom', 'other',
]
const EMPTY_TEMPLATE: TroubleshootingTemplate = {
name: '',
category: 'system',
description: '',
estimated_time: 5,
difficulty: 2,
tags: [],
root_node: {
id: 'fc-new-1',
type: 'step',
label: '请修改此步骤',
children: [],
},
}
// =============================================================================
// 响应式状态
// =============================================================================
const form = reactive<TroubleshootingTemplate>({ ...EMPTY_TEMPLATE })
const jsonText = ref<string>('')
const parseOk = ref<boolean>(true)
const parseError = ref<string>('')
const parsedData = ref<TroubleshootingTemplate | null>(null)
const editorHeight = ref<number>(500)
// 编辑器配置
const cmOptions = {
mode: 'application/json',
theme: 'oneDark',
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
indentUnit: 2,
smartIndent: true,
matchBrackets: true,
autoCloseBrackets: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
extensions: [json(), oneDark],
}
// =============================================================================
// 计算属性
// =============================================================================
const dialogTitle = computed(() => {
if (props.readonly) return `👁 预览: ${form.name || '未命名'}`
return props.template?.id ? `✎ 编辑: ${form.name || '未命名'}` : '+ 新建流程图'
})
const stats = computed(() => ({
nodes: parseOk.value && parsedData.value ? countNodes(parsedData.value.root_node) : 0,
decisions: parseOk.value && parsedData.value ? countDecisions(parsedData.value.root_node) : 0,
}))
const rootPath = computed(() => 'root')
// =============================================================================
// watch
// =============================================================================
watch(
() => props.template,
(newTpl) => {
if (newTpl) {
Object.assign(form, newTpl)
jsonText.value = formatJson(newTpl)
onCodeChange()
} else {
Object.assign(form, EMPTY_TEMPLATE)
jsonText.value = formatJson(EMPTY_TEMPLATE)
onCodeChange()
}
},
{ immediate: true }
)
watch(jsonText, () => {
// 实时同步到 form(让顶部表单跟着 JSON 变)
if (parseOk.value && parsedData.value) {
form.name = parsedData.value.name || form.name
form.category = parsedData.value.category || form.category
form.estimated_time = parsedData.value.estimated_time ?? form.estimated_time
form.difficulty = parsedData.value.difficulty ?? form.difficulty
form.tags = parsedData.value.tags || form.tags
}
})
// =============================================================================
// 生命周期
// =============================================================================
function updateEditorHeight() {
editorHeight.value = Math.max(window.innerHeight - 380, 300)
}
onMounted(() => {
updateEditorHeight()
window.addEventListener('resize', updateEditorHeight)
})
onUnmounted(() => {
window.removeEventListener('resize', updateEditorHeight)
})
// =============================================================================
// 方法
// =============================================================================
function onCodeChange() {
const result = validateJson(jsonText.value)
if (result.ok) {
parseOk.value = true
parseError.value = ''
parsedData.value = result.data
} else {
parseOk.value = false
parseError.value = result.error
parsedData.value = null
}
}
function handleFormat() {
if (parseOk.value && parsedData.value) {
jsonText.value = formatJson(parsedData.value)
ElMessage.success('JSON 已格式化')
}
}
async function handleCopy() {
try {
await navigator.clipboard.writeText(jsonText.value)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动 Ctrl+C')
}
}
function handleExport() {
if (!parseOk.value || !parsedData.value) return
const blob = new Blob([jsonText.value], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${(form.name || 'flowchart').replace(/\s+/g, '-')}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('已导出')
}
function handleCancel() {
emit('update:modelValue', false)
emit('cancel')
}
function handleSave() {
if (!parseOk.value || !parsedData.value) {
ElMessage.error('JSON 无效,无法保存')
return
}
// 合并:form 基本信息 + parsedData JSON 内容
const finalTpl: TroubleshootingTemplate = {
...parsedData.value,
name: form.name,
category: form.category,
description: form.description ?? parsedData.value.description,
estimated_time: form.estimated_time,
difficulty: form.difficulty,
tags: form.tags,
}
emit('save', finalTpl)
}
</script>
<style scoped>
.basic-info {
padding: 12px 0;
background: #fafafa;
border-radius: 6px;
margin-bottom: 12px;
}
.basic-info :deep(.el-form-item) {
margin-bottom: 0;
}
.basic-info .suffix {
margin-left: 4px;
color: #909399;
font-size: 12px;
}
.dual-pane {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
height: 540px;
}
.pane {
display: flex;
flex-direction: column;
border: 1px solid #e4e7ed;
border-radius: 6px;
overflow: hidden;
background: white;
}
.pane-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
font-size: 13px;
}
.pane-title {
font-weight: 600;
color: #303133;
}
.pane-stats {
font-size: 12px;
color: #909399;
}
.pane-body {
flex: 1;
overflow: auto;
padding: 8px;
}
.right-pane .pane-body {
background: #fafbfc;
}
.tree-wrapper {
font-size: 13px;
}
.tree-error {
color: #f56c6c;
padding: 16px;
}
.tree-error pre {
background: #fef0f0;
padding: 8px;
border-radius: 4px;
font-size: 12px;
white-space: pre-wrap;
}
.tree-error .hint {
margin-top: 12px;
color: #909399;
font-size: 12px;
}
.tree-error ul {
margin: 4px 0;
padding-left: 20px;
color: #909399;
font-size: 12px;
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-left,
.footer-right {
display: flex;
gap: 8px;
}
:deep(.el-dialog__body) {
padding: 16px 20px;
}
:deep(.CodeMirror) {
height: 100%;
font-family: 'Fira Code', 'Source Code Pro', monospace;
font-size: 13px;
}
</style>
+308 -156
View File
@@ -2,230 +2,382 @@
=============================================================================
企微IT智能服务台 排查流程图管理页
=============================================================================
说明JSON 导入导出 + 预览 + 版本管理
阶段三开始实现当前为占位功能
显示模板列表 + 灰化的导入/导出/新建按钮
底部展示实现路径
说明JSON 导入导出 + 在线编辑 + 树形预览
阶段三实现 - 用户已选 B 方案(双栏 CodeMirror + vue-json-pretty)
功能:
- 列表( /api/troubleshooting-templates 拉取)
- 预览/编辑/删除(单条)
- 导入 JSON(文件)
- 导出全部(批量下载)
- 新建(空模板)
=============================================================================
-->
<template>
<div class="flowcharts-page">
<!-- 页面标题 -->
<div class="page-title">排查流程图管理</div>
<div class="page-desc">JSON 导入导出 + 预览 + 版本管理阶段三开始实现后续升级为可视化拖拽编辑</div>
<div class="page-header">
<div>
<div class="page-title">排查流程图管理</div>
<div class="page-desc">JSON 导入导出 + 在线编辑 + 树形预览 {{ flowcharts.length }} 套模板</div>
</div>
</div>
<!-- 操作按钮灰化占位 -->
<!-- 操作按钮 -->
<div class="flowchart-actions">
<el-button type="primary" disabled>
<el-button type="primary" @click="handleImport">
<el-icon><Upload /></el-icon>
导入 JSON
</el-button>
<el-button disabled>
<el-button @click="handleExportAll" :disabled="flowcharts.length === 0">
<el-icon><Download /></el-icon>
导出全部
</el-button>
<el-button disabled>
<el-button @click="handleNew">
<el-icon><Plus /></el-icon>
新建流程图
</el-button>
<el-button @click="loadList" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<!-- 流程图模板表格 -->
<div class="table-wrapper">
<el-table
v-loading="loading"
:data="flowcharts"
style="width: 100%"
:header-cell-style="{ background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', fontSize: '12px' }"
:cell-style="{ color: 'var(--text-primary)', fontSize: '13px' }"
row-class-name="flowchart-table-row"
empty-text="暂无流程图,点击新建流程图开始"
>
<el-table-column label="流程图名称" min-width="200">
<el-table-column label="流程图名称" min-width="220">
<template #default="{ row }">
<div class="flowchart-name">
<el-icon :size="16" style="color: var(--accent); margin-right: 6px">
<Share />
</el-icon>
{{ row.name }}
<span>{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="分类" width="80">
<el-table-column label="分类" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column label="节点数" width="80" align="center" prop="nodeCount" />
<el-table-column label="版本" width="70" align="center" prop="version" />
<el-table-column label="最后更新" width="110" prop="updatedAt" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.statusType" size="small">
{{ row.statusText }}
</el-tag>
</template>
<el-table-column label="预估时间" width="90" align="center">
<template #default="{ row }">{{ row.estimated_time ?? '-' }} 分钟</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template #default>
<el-button size="small" text type="primary" disabled>预览</el-button>
<el-button size="small" text disabled>编辑</el-button>
<el-table-column label="版本" width="70" align="center">
<template #default="{ row }">{{ row.version || 'v1.0' }}</template>
</el-table-column>
<el-table-column label="最后更新" width="120" align="center">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="handlePreview(row)">
<el-icon><View /></el-icon>
预览
</el-button>
<el-button size="small" text type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" text type="danger" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 实现路径 -->
<div class="roadmap-section">
<div class="roadmap-title">
<el-icon :size="16" style="color: var(--accent); margin-right: 6px"><Flag /></el-icon>
实现路径
</div>
<div class="roadmap-steps">
<div class="roadmap-step active">
<div class="step-number">Step 1</div>
<div class="step-title">JSON 导入导出 + 预览</div>
<div class="step-phase">阶段三 3B</div>
</div>
<el-icon :size="16" class="roadmap-arrow"><ArrowRight /></el-icon>
<div class="roadmap-step">
<div class="step-number">Step 2</div>
<div class="step-title">导出为 Dify 变量</div>
<div class="step-phase">阶段四 4A</div>
</div>
<el-icon :size="16" class="roadmap-arrow"><ArrowRight /></el-icon>
<div class="roadmap-step">
<div class="step-number">Step 3</div>
<div class="step-title">Dify HTTP 回调</div>
<div class="step-phase">阶段四</div>
</div>
<el-icon :size="16" class="roadmap-arrow"><ArrowRight /></el-icon>
<div class="roadmap-step">
<div class="step-number">Step 4</div>
<div class="step-title">可视化拖拽编辑</div>
<div class="step-phase">远景</div>
</div>
</div>
</div>
<!-- 在线编辑器对话框 -->
<FlowchartEditorDialog
v-model="dialogVisible"
:template="currentTemplate"
:readonly="dialogMode === 'preview'"
:saving="saving"
@save="handleSave"
@cancel="handleDialogCancel"
/>
<!-- 隐藏的文件选择器(导入 JSON) -->
<input
ref="fileInputRef"
type="file"
accept=".json,application/json"
style="display: none"
@change="handleFileSelected"
/>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// Demo 数据
// ==========================================================================
const flowcharts = [
{
name: 'VPN连接故障排查',
category: '网络',
nodeCount: 12,
version: 'v2.1',
updatedAt: '2026-06-10',
statusType: 'success',
statusText: '已发布',
},
{
name: '打印机脱机排查',
category: '外设',
nodeCount: 8,
version: 'v1.3',
updatedAt: '2026-06-08',
statusType: 'success',
statusText: '已发布',
},
{
name: '邮箱登录失败排查',
category: '软件',
nodeCount: 10,
version: 'v1.0',
updatedAt: '2026-06-06',
statusType: 'warning',
statusText: '草稿',
},
]
// =============================================================================
// imports
// =============================================================================
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Upload, Download, Plus, Refresh,
Share, View, Edit, Delete,
} from '@element-plus/icons-vue'
import FlowchartEditorDialog from '@/components/flowchart/FlowchartEditorDialog.vue'
import {
listTemplates,
getTemplate,
createTemplate,
updateTemplate,
deleteTemplate,
formatJson,
countNodes,
type TroubleshootingTemplate,
} from '@/api/troubleshooting'
// =============================================================================
// 响应式状态
// =============================================================================
const flowcharts = ref<TroubleshootingTemplate[]>([])
const loading = ref<boolean>(false)
const saving = ref<boolean>(false)
const dialogVisible = ref<boolean>(false)
const dialogMode = ref<'preview' | 'edit' | 'create'>('preview')
const currentTemplate = ref<TroubleshootingTemplate | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
// =============================================================================
// 加载列表
// =============================================================================
async function loadList() {
loading.value = true
try {
const list = await listTemplates()
// 附加节点数(便于表格展示)
flowcharts.value = list.map((t) => ({
...t,
nodeCount: countNodes(t.root_node),
}))
} catch (e) {
ElMessage.error('加载流程图列表失败')
console.error(e)
} finally {
loading.value = false
}
}
onMounted(loadList)
// =============================================================================
// 操作
// =============================================================================
// 预览
async function handlePreview(row: TroubleshootingTemplate) {
try {
// 重新拉详情(确保数据最新)
const tpl = await getTemplate(row.id!)
currentTemplate.value = tpl
dialogMode.value = 'preview'
dialogVisible.value = true
} catch {
// 拉失败就用列表里那条
currentTemplate.value = row
dialogMode.value = 'preview'
dialogVisible.value = true
}
}
// 编辑
async function handleEdit(row: TroubleshootingTemplate) {
try {
const tpl = await getTemplate(row.id!)
currentTemplate.value = tpl
dialogMode.value = 'edit'
dialogVisible.value = true
} catch {
currentTemplate.value = row
dialogMode.value = 'edit'
dialogVisible.value = true
}
}
// 新建
function handleNew() {
currentTemplate.value = null
dialogMode.value = 'create'
dialogVisible.value = true
}
// 删除
async function handleDelete(row: TroubleshootingTemplate) {
try {
await ElMessageBox.confirm(
`确定要删除流程图「${row.name}」吗?此操作不可恢复。`,
'删除确认',
{
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
}
)
} catch {
return // 用户取消
}
try {
await deleteTemplate(row.id!)
ElMessage.success('已删除')
await loadList()
} catch (e) {
ElMessage.error('删除失败')
console.error(e)
}
}
// 保存
async function handleSave(tpl: TroubleshootingTemplate) {
saving.value = true
try {
if (dialogMode.value === 'create' || !tpl.id) {
await createTemplate(tpl)
ElMessage.success('创建成功')
} else {
await updateTemplate(tpl.id, tpl)
ElMessage.success('更新成功')
}
dialogVisible.value = false
await loadList()
} catch (e) {
ElMessage.error('保存失败,请检查 JSON 格式')
console.error(e)
} finally {
saving.value = false
}
}
// 取消
function handleDialogCancel() {
dialogVisible.value = false
}
// =============================================================================
// 导入 / 导出
// =============================================================================
function handleImport() {
fileInputRef.value?.click()
}
async function handleFileSelected(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
try {
const text = await file.text()
const result = JSON.parse(text) as TroubleshootingTemplate
// 简单校验
if (!result.name || !result.category || !result.root_node) {
throw new Error('JSON 缺少必要字段(name/category/root_node)')
}
// 把导入的 JSON 当作"新建"打开,让用户确认/编辑
currentTemplate.value = result
dialogMode.value = 'create'
dialogVisible.value = true
ElMessage.success('JSON 解析成功,请确认后保存')
} catch (e) {
const err = e as Error
ElMessage.error(`JSON 解析失败: ${err.message}`)
} finally {
// 清 input 以便下次能选同一文件
target.value = ''
}
}
function handleExportAll() {
const data = flowcharts.value.map((t) => ({
...t,
// 去掉统计字段,只导出核心数据
nodeCount: undefined,
}))
const json = formatJson(data)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `troubleshooting-templates-all-${Date.now()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${data.length} 条流程图`)
}
// =============================================================================
// 工具
// =============================================================================
function formatDate(iso?: string): string {
if (!iso) return '-'
try {
return new Date(iso).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\//g, '-')
} catch {
return iso
}
}
</script>
<style scoped>
/* 操作按钮 */
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.page-desc {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
.flowchart-actions {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
/* 流程图名称 */
.flowchart-name {
display: flex;
align-items: center;
}
/* 实现路径区域 */
.roadmap-section {
margin-top: 24px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
}
.roadmap-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
display: flex;
align-items: center;
color: var(--text-primary);
}
.roadmap-steps {
display: flex;
gap: 0;
align-items: center;
}
.roadmap-step {
border-radius: var(--radius);
padding: 12px 16px;
flex: 1;
text-align: center;
background: var(--bg-primary);
border: 1px solid var(--border);
}
.roadmap-step.active {
background: var(--accent-light);
border-color: var(--accent);
}
.step-number {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.roadmap-step.active .step-number {
color: var(--accent);
}
.step-title {
font-size: 13px;
margin-top: 4px;
color: var(--text-secondary);
}
.roadmap-step.active .step-title {
color: var(--text-primary);
}
.step-phase {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.roadmap-arrow {
color: var(--text-muted);
flex-shrink: 0;
margin: 0 4px;
.table-wrapper {
background: white;
border-radius: var(--radius-lg);
padding: 4px;
}
</style>
<style>
/* 流程图表格行悬停 */
.flowchart-table-row:hover td {
background-color: var(--bg-tertiary) !important;
}