feat(admin): knowledge iteration + ragflow ingestion views

新增 KnowledgeIteration/RagflowIngestion 视图; api/troubleshooting/admin store 适配; 锁定 pnpm-lock.yaml。
This commit is contained in:
Simon
2026-07-09 11:50:08 +08:00
parent 018c87e10b
commit 584c975e7f
12 changed files with 3607 additions and 24 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ apiClient.interceptors.response.use(
}
// 业务成功:Scheme A — 直接返回 inner data(三端统一契约)
return res.data
return res.data as any
},
(error) => {
// 网络错误或服务器错误(HTTP 状态码非 2xx)— 统一 reject {code, message}CTRT-03
+1 -1
View File
@@ -91,7 +91,7 @@ export async function updateTemplate(
id: string,
data: TroubleshootingTemplate
): Promise<TroubleshootingTemplate> {
return await apiClient.put<TroubleshootingTemplate>(`/troubleshooting-templates/${id}`, data)
return await apiClient.put(`/troubleshooting-templates/${id}`, data) as any
}
/** DELETE /api/troubleshooting-templates/{id} — 删除模板 */
+3 -3
View File
@@ -69,7 +69,7 @@ export const useAdminStore = defineStore('admin', () => {
logging.value = true
try {
// 注意:后端 AgentLogin Schema 要求必填字段 name
const response = await apiClient.post('/agents/login', {
const response: any = await apiClient.post('/agents/login', {
user_id: inputUserId,
name: inputUserId, // 后端必填字段,使用 user_id 作为默认值
password: password,
@@ -144,8 +144,8 @@ export const useAdminStore = defineStore('admin', () => {
async function refreshAdminInfo(): Promise<void> {
try {
if (!token.value) return
const response = await apiClient.get('/agents/me')
adminInfo.value = response
const response: any = await apiClient.get('/agents/me')
adminInfo.value = response as any
} catch (error) {
console.error('获取管理员信息失败:', error)
// 如果是 401 未授权,说明 token 过期,需要重新登录
+4 -4
View File
@@ -177,14 +177,14 @@ onMounted(async () => {
loading.value = true
try {
const response = await getDashboardOverview()
const data = response.data.data
Object.assign(overview, data)
// 拦截器已解包到 inner dataresponse 直接就是 {online_agents, today_conversations, ...}
Object.assign(overview, response)
// 构建待处理事项
buildPendingItems(data.system_alerts || [])
buildPendingItems(response.system_alerts || [])
// 构建系统健康
buildHealthItems(data.integrations_health || [])
buildHealthItems(response.integrations_health || [])
} catch (error) {
console.error('加载仪表盘数据失败:', error)
// 使用默认 demo 数据
@@ -0,0 +1,981 @@
<!-- =============================================================================
企微IT智能服务台 管理后台 知识迭代提案管理页Tier1 / P0-6 / P2-1
=============================================================================
说明管理后台的知识迭代提案管理页面支持
- 提案列表含筛选/分页
- 审阅提案详情
- 图字段编辑issue/action/relation/parent
- 训练师手动录入入口通道 B
D7 硬约束
- 内联审批 + 独立队列
- 提案默认 pending
============================================================================= -->
<template>
<div class="knowledge-iteration-page">
<!-- 页面标题 -->
<div class="knowledge-iteration-page__header">
<h2>📚 知识迭代管理</h2>
<div class="knowledge-iteration-page__header-actions">
<el-button-group>
<el-button :type="viewMode === 'list' ? 'primary' : ''" @click="viewMode = 'list'">
列表
</el-button>
<el-button :type="viewMode === 'graph' ? 'primary' : ''" @click="switchToGraph">
图谱
</el-button>
</el-button-group>
<el-button type="primary" @click="showCreateDialog = true">
+ 手动录入通道 B
</el-button>
<el-button :loading="analyzing" @click="triggerAnalyze">
触发分析
</el-button>
</div>
</div>
<!-- 统计概要 -->
<div class="knowledge-iteration-page__stats-bar">
<div
v-for="stat in statsItems"
:key="stat.key"
class="knowledge-iteration-page__stat-item"
>
<span class="knowledge-iteration-page__stat-value">{{ stat.value }}</span>
<span class="knowledge-iteration-page__stat-label">{{ stat.label }}</span>
</div>
</div>
<!-- ===== 列表视图 ===== -->
<template v-if="viewMode === 'list'">
<!-- 筛选栏 -->
<div class="knowledge-iteration-page__filters">
<el-select
v-model="filterStatus"
placeholder="状态"
clearable
size="default"
style="width: 140px"
@change="fetchSuggestions"
>
<el-option label="全部" value="" />
<el-option label="待审核" value="pending" />
<el-option label="队列中" value="queued" />
<el-option label="已通过" value="approved" />
<el-option label="已驳回" value="rejected" />
<el-option label="已应用" value="applied" />
<el-option label="已同步" value="graph_synced" />
</el-select>
<el-select
v-model="filterAudience"
placeholder="受众"
clearable
size="default"
style="width: 160px"
@change="fetchSuggestions"
>
<el-option label="全部" value="" />
<el-option label="员工快捷回复" value="employee_quick_reply" />
<el-option label="工程师作业指导" value="engineer_workguide" />
</el-select>
<el-input
v-model="filterConfMin"
placeholder="置信度下限"
size="default"
type="number"
style="width: 120px"
@change="fetchSuggestions"
/>
<el-input
v-model="filterConfMax"
placeholder="置信度上限"
size="default"
type="number"
style="width: 120px"
@change="fetchSuggestions"
/>
<el-button :icon="'Refresh'" @click="fetchSuggestions">刷新</el-button>
</div>
<!-- 提案表格 -->
<div class="knowledge-iteration-page__table" v-loading="loading">
<el-table :data="suggestions" stripe size="default">
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="suggestion_type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.suggestion_type === 'new_faq' ? 'success' : 'warning'" size="small">
{{ row.suggestion_type === 'new_faq' ? '新FAQ' : '更新' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="confidence" label="置信度" width="90" align="center">
<template #default="{ row }">
<span :style="{ color: confidenceColor(row.confidence) }">
{{ confidencePercent(row.confidence) }}%
</span>
</template>
</el-table-column>
<el-table-column prop="audience" label="受众" width="120">
<template #default="{ row }">
{{ audienceLabel(row.audience) }}
</template>
</el-table-column>
<el-table-column prop="source_type" label="来源" width="100">
<template #default="{ row }">
{{ sourceTypeLabel(row.source_type) }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="150">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button
v-if="isApprovable(row)"
size="small"
type="success"
@click="approveEdit(row)"
>
采纳
</el-button>
<el-button
v-if="isApprovable(row)"
size="small"
type="danger"
@click="rejectSuggestion(row)"
>
驳回
</el-button>
<el-button size="small" @click="viewDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="knowledge-iteration-page__pagination">
<el-pagination
v-model:current-page="page"
:total="total"
:page-size="pageSize"
layout="prev, pager, next"
@current-change="fetchSuggestions"
/>
</div>
</template>
<!-- ===== 图谱视图任务2P2 ECharts力导向图 ===== -->
<template v-if="viewMode === 'graph'">
<div class="knowledge-iteration-page__graph-filters">
<el-input
v-model="graphIssueFilter"
placeholder="按 Issue 名称筛选子图(留空查全图)"
size="default"
style="width: 280px"
clearable
@change="fetchGraphData"
/>
<el-button :loading="graphLoading" @click="fetchGraphData">
刷新图谱
</el-button>
<span class="knowledge-iteration-page__graph-info">
节点: {{ graphNodeCount }} | 关系: {{ graphLinkCount }}
</span>
</div>
<div
ref="graphContainer"
class="knowledge-iteration-page__graph-container"
v-loading="graphLoading"
/>
</template>
<!-- 详情/编辑弹窗 -->
<el-dialog
v-model="showDetailDialog"
:title="editingSuggestion ? '提案详情与编辑' : '提案详情'"
width="650px"
>
<template v-if="editingSuggestion">
<el-form label-width="80px" size="default">
<el-form-item label="标题">
<el-input v-model="editForm.title" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="editForm.content" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="editForm.category">
<el-option label="硬件" value="硬件" />
<el-option label="软件" value="软件" />
<el-option label="网络" value="网络" />
<el-option label="安全" value="安全" />
<el-option label="账号" value="账号" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="受众">
<el-select v-model="editForm.audience">
<el-option label="员工快捷回复" value="employee_quick_reply" />
<el-option label="工程师作业指导" value="engineer_workguide" />
</el-select>
</el-form-item>
<!-- 图字段编辑D1 -->
<el-divider>知识图谱字段</el-divider>
<el-form-item label="Issue 节点">
<el-input v-model="editForm.issue" placeholder="问题名称,如'VPN问题'" />
</el-form-item>
<el-form-item label="Action 节点">
<el-input v-model="editForm.action" placeholder="动作名称,如'个人VPN开通'" />
</el-form-item>
<el-form-item label="关系类型">
<el-select v-model="editForm.relation_type">
<el-option label="LEADS_TO" value="LEADS_TO" />
<el-option label="RELATES_TO" value="RELATES_TO" />
<el-option label="CAN_JUMP_TO" value="CAN_JUMP_TO" />
</el-select>
</el-form-item>
<el-form-item label="父 Issue">
<el-input v-model="editForm.parent_issue" placeholder="(可选)父级问题名称" />
</el-form-item>
</el-form>
</template>
<template #footer>
<el-button @click="showDetailDialog = false">关闭</el-button>
<el-button
v-if="editingSuggestion && isApprovable(editingSuggestion)"
type="warning"
:loading="checkingDup"
@click="checkDuplicates(editingSuggestion)"
>
检查重复
</el-button>
<el-button
v-if="editingSuggestion && isApprovable(editingSuggestion)"
type="primary"
@click="submitRewrite"
>
提交改写
</el-button>
</template>
</el-dialog>
<!-- 重复检查结果弹窗 -->
<el-dialog
v-model="showDupDialog"
title="⚠ 重复检测结果"
width="550px"
>
<div v-if="dupResults.length === 0" style="color: #67c23a; text-align: center; padding: 20px;">
未发现重复可安全采纳
</div>
<div v-else>
<el-alert
title="发现可能重复的知识条目,建议合并而非新建"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 12px;"
/>
<el-table :data="dupResults" size="small" max-height="300">
<el-table-column prop="name" label="名称" show-overflow-tooltip />
<el-table-column prop="type" label="匹配类型" width="120">
<template #default="{ row }">
<el-tag :type="row.type === 'same_issue' ? 'danger' : 'warning'" size="small">
{{ dupTypeLabel(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="similarity" label="相似度" width="80" align="center">
<template #default="{ row }">
{{ Math.round((row.similarity ?? 0) * 100) }}%
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button
v-if="row.suggestion_id"
size="small"
type="primary"
@click="handleMerge(row.suggestion_id)"
>
合并
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
<!-- 手动录入弹窗通道 B -->
<el-dialog
v-model="showCreateDialog"
title="手动录入知识建议(通道 B)"
width="650px"
>
<el-form ref="createFormRef" :model="createForm" label-width="80px" size="default">
<el-form-item label="标题" required>
<el-input v-model="createForm.title" placeholder="知识条目标题" />
</el-form-item>
<el-form-item label="内容" required>
<el-input v-model="createForm.content" type="textarea" :rows="4" placeholder="答案内容" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="createForm.category">
<el-option label="硬件" value="硬件" />
<el-option label="软件" value="软件" />
<el-option label="网络" value="网络" />
<el-option label="安全" value="安全" />
<el-option label="账号" value="账号" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="受众">
<el-select v-model="createForm.audience">
<el-option label="员工快捷回复" value="employee_quick_reply" />
<el-option label="工程师作业指导" value="engineer_workguide" />
</el-select>
</el-form-item>
<el-divider>知识图谱字段可选</el-divider>
<el-form-item label="Issue">
<el-input v-model="createForm.issue" placeholder="问题名称" />
</el-form-item>
<el-form-item label="Action">
<el-input v-model="createForm.action" placeholder="动作名称" />
</el-form-item>
<el-form-item label="关系类型">
<el-select v-model="createForm.relation_type">
<el-option label="LEADS_TO" value="LEADS_TO" />
<el-option label="RELATES_TO" value="RELATES_TO" />
<el-option label="CAN_JUMP_TO" value="CAN_JUMP_TO" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="createSuggestion">
提交待审
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
import * as echarts from 'echarts'
// ===========================================================================
// 类型
// ===========================================================================
interface SuggestionItem {
id: string
suggestion_type: string
status: string
title: string
content: string
category: string
tags: string[]
source_type: string
source_data?: string[]
confidence?: number | null
audience?: string | null
issue?: string | null
action?: string | null
relation_type?: string | null
parent_issue?: string | null
graph_meta?: any
graph_sync_status?: string | null
source_failed?: boolean
reviewer_id?: string | null
reviewed_at?: string | null
created_at: string
updated_at: string
}
// ===========================================================================
// 状态
// ===========================================================================
const suggestions = ref<SuggestionItem[]>([])
const total = ref(0)
const loading = ref(false)
const analyzing = ref(false)
const creating = ref(false)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref('')
const filterAudience = ref('')
const filterConfMin = ref<number | undefined>()
const filterConfMax = ref<number | undefined>()
const showDetailDialog = ref(false)
const showCreateDialog = ref(false)
const editingSuggestion = ref<SuggestionItem | null>(null)
// ── 图谱视图状态(任务2:P2) ──
const viewMode = ref<'list' | 'graph'>('list')
const graphLoading = ref(false)
const graphIssueFilter = ref('')
const graphContainer = ref<HTMLDivElement | null>(null)
let graphChart: echarts.ECharts | null = null
const graphNodeCount = ref(0)
const graphLinkCount = ref(0)
// ── 重复检查状态(任务3:P2) ──
const checkingDup = ref(false)
const showDupDialog = ref(false)
const dupResults = ref<any[]>([])
const editForm = reactive({
title: '',
content: '',
category: '',
audience: '',
issue: '',
action: '',
relation_type: '',
parent_issue: '',
})
const createForm = reactive({
title: '',
content: '',
category: '其他',
audience: 'engineer_workguide' as string,
issue: '',
action: '',
relation_type: 'LEADS_TO' as string,
})
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// 统计
const statsItems = ref([
{ key: 'pending', label: '待审核', value: 0 },
{ key: 'queued', label: '队列中', value: 0 },
{ key: 'approved', label: '已通过', value: 0 },
{ key: 'applied', label: '已应用', value: 0 },
{ key: 'graph_synced', label: '已同步', value: 0 },
])
// ===========================================================================
// 方法
// ===========================================================================
async function fetchSuggestions() {
loading.value = true
try {
const params = new URLSearchParams()
if (filterStatus.value) params.set('status', filterStatus.value)
if (filterAudience.value) params.set('audience', filterAudience.value)
if (filterConfMin.value !== undefined && filterConfMin.value !== null) {
params.set('confidence_min', String(filterConfMin.value / 100))
}
if (filterConfMax.value !== undefined && filterConfMax.value !== null) {
params.set('confidence_max', String(filterConfMax.value / 100))
}
params.set('page', String(page.value))
params.set('page_size', String(pageSize.value))
const res = await fetch(`/api/admin/knowledge-iteration/suggestions?${params}`)
const json = await res.json()
if (json.code === 0 && json.data) {
suggestions.value = json.data.items || []
total.value = json.data.total || 0
}
} catch (err) {
console.error('获取建议列表失败:', err)
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const res = await fetch('/api/admin/knowledge-iteration/stats')
const json = await res.json()
if (json.code === 0 && json.data) {
for (const stat of statsItems.value) {
stat.value = json.data[stat.key] || 0
}
}
} catch (err) {
console.error('获取统计失败:', err)
}
}
async function triggerAnalyze() {
analyzing.value = true
try {
const res = await fetch('/api/admin/knowledge-iteration/analyze', { method: 'POST' })
const json = await res.json()
if (json.code === 0) {
// 分析完成,刷新列表
await Promise.all([fetchSuggestions(), fetchStats()])
}
} catch (err) {
console.error('触发分析失败:', err)
} finally {
analyzing.value = false
}
}
function isApprovable(row: SuggestionItem): boolean {
return row.status === 'pending' || row.status === 'queued'
}
async function approveEdit(row: SuggestionItem) {
try {
const res = await fetch(`/api/admin/knowledge-iteration/suggestions/${row.id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const json = await res.json()
if (json.code === 0) {
await Promise.all([fetchSuggestions(), fetchStats()])
}
} catch (err) {
console.error('审批失败:', err)
}
}
async function rejectSuggestion(row: SuggestionItem) {
try {
const res = await fetch(`/api/admin/knowledge-iteration/suggestions/${row.id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reject_reason: '审核不通过' }),
})
const json = await res.json()
if (json.code === 0) {
await Promise.all([fetchSuggestions(), fetchStats()])
}
} catch (err) {
console.error('驳回失败:', err)
}
}
function viewDetail(row: SuggestionItem) {
editingSuggestion.value = row
editForm.title = row.title
editForm.content = row.content
editForm.category = row.category
editForm.audience = row.audience || ''
editForm.issue = row.issue || ''
editForm.action = row.action || ''
editForm.relation_type = row.relation_type || ''
editForm.parent_issue = row.parent_issue || ''
showDetailDialog.value = true
}
async function submitRewrite() {
if (!editingSuggestion.value) return
try {
const res = await fetch(
`/api/admin/knowledge-iteration/suggestions/${editingSuggestion.value.id}/rewrite`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: editForm.title || undefined,
content: editForm.content || undefined,
category: editForm.category || undefined,
audience: editForm.audience || undefined,
issue: editForm.issue || undefined,
action: editForm.action || undefined,
relation_type: editForm.relation_type || undefined,
parent_issue: editForm.parent_issue || undefined,
}),
},
)
const json = await res.json()
if (json.code === 0) {
showDetailDialog.value = false
await Promise.all([fetchSuggestions(), fetchStats()])
}
} catch (err) {
console.error('改写失败:', err)
}
}
async function createSuggestion() {
creating.value = true
try {
const res = await fetch('/api/admin/knowledge-iteration/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
suggestion_type: 'new_faq',
title: createForm.title,
content: createForm.content,
category: createForm.category,
source_type: 'manual',
audience: createForm.audience,
confidence: 1.0,
issue: createForm.issue || undefined,
action: createForm.action || undefined,
relation_type: createForm.relation_type || undefined,
}),
})
const json = await res.json()
if (json.code === 0) {
showCreateDialog.value = false
// 重置表单
createForm.title = ''
createForm.content = ''
await Promise.all([fetchSuggestions(), fetchStats()])
}
} catch (err) {
console.error('创建建议失败:', err)
} finally {
creating.value = false
}
}
// ===========================================================================
// 工具函数
// ===========================================================================
function statusTagType(status: string): string {
const map: Record<string, string> = {
pending: 'warning',
queued: 'info',
approved: 'success',
rejected: 'danger',
applied: 'success',
graph_synced: '',
expired: 'info',
}
return map[status] || 'info'
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
pending: '待审核',
queued: '队列中',
approved: '已通过',
rejected: '已驳回',
applied: '已应用',
graph_synced: '已同步',
expired: '已过期',
}
return map[status] || status
}
function confidencePercent(c: number | null | undefined): number {
return Math.round((c ?? 0) * 100)
}
function confidenceColor(c: number | null | undefined): string {
const v = c ?? 0
if (v >= 0.7) return '#67c23a'
if (v >= 0.5) return '#e6a23c'
return '#f56c6c'
}
function audienceLabel(a: string | null | undefined): string {
const map: Record<string, string> = {
employee_quick_reply: '员工快捷回复',
engineer_workguide: '工程师指导',
}
return map[a || ''] || a || '—'
}
function sourceTypeLabel(type: string): string {
const map: Record<string, string> = {
annotation: '标注分析',
conversation: '会话分析',
ai_uncertain: 'AI不确定',
manual: '手动录入',
document_ragflow: 'RAGFlow',
}
return map[type] || type
}
function formatTime(iso: string): string {
if (!iso) return '—'
return new Date(iso).toLocaleString('zh-CN')
}
// ===========================================================================
// 图谱视图方法(任务2:P2 ECharts力导向图)
// ===========================================================================
async function switchToGraph() {
viewMode.value = 'graph'
await nextTick()
await fetchGraphData()
}
async function fetchGraphData() {
graphLoading.value = true
try {
const params = new URLSearchParams()
params.set('limit', '200')
if (graphIssueFilter.value) {
params.set('issue_name', graphIssueFilter.value)
}
const res = await fetch(`/api/admin/knowledge-iteration/graph?${params}`)
const json = await res.json()
if (json.code === 0 && json.data) {
graphNodeCount.value = json.data.nodes?.length || 0
graphLinkCount.value = json.data.links?.length || 0
await nextTick()
renderGraph(json.data)
}
} catch (err) {
console.error('获取图谱数据失败:', err)
} finally {
graphLoading.value = false
}
}
function renderGraph(data: { nodes: any[], links: any[] }) {
if (!graphContainer.value) return
if (graphChart) {
graphChart.dispose()
graphChart = null
}
graphChart = echarts.init(graphContainer.value)
// 节点分类映射颜色
const categories = [
{ name: 'issue', itemStyle: { color: '#67c23a' } },
{ name: 'action', itemStyle: { color: '#e6a23c' } },
]
const nodes = (data.nodes || []).map((n: any) => ({
id: n.id,
name: n.name || n.label || n.id,
category: n.type || 'issue',
symbolSize: n.type === 'issue' ? 28 : 22,
label: { show: true, fontSize: 11 },
draggable: true,
}))
const links = (data.links || []).map((l: any) => ({
source: l.source,
target: l.target,
label: { show: true, formatter: l.type || '', fontSize: 9 },
lineStyle: { color: '#c0c4cc', width: Math.max(0.5, (l.weight || 1) * 1.5) },
}))
graphChart.setOption({
tooltip: {
formatter: (params: any) => {
if (params.dataType === 'node') return `<b>${params.name}</b><br/>类型: ${params.data.category}`
return `${params.data.label?.formatter || ''}`
},
},
legend: [{ data: categories.map((c) => c.name), orient: 'vertical', right: 10, top: 10 }],
series: [{
type: 'graph',
layout: 'force',
data: nodes,
links: links,
categories: categories,
roam: true,
force: {
repulsion: 300,
gravity: 0.1,
edgeLength: [100, 250],
layoutAnimation: true,
},
emphasis: { focus: 'adjacency', lineStyle: { width: 4 } },
lineStyle: { color: '#c0c4cc', curveness: 0.3 },
}],
})
// 响应式 resize
const observer = new ResizeObserver(() => graphChart?.resize())
observer.observe(graphContainer.value)
}
// ===========================================================================
// 重复检查与合并方法(任务3:P2)
// ===========================================================================
async function checkDuplicates(row: SuggestionItem) {
checkingDup.value = true
showDupDialog.value = true
try {
const res = await fetch(`/api/admin/knowledge-iteration/suggestions/${row.id}/duplicates`)
const json = await res.json()
if (json.code === 0 && json.data) {
dupResults.value = json.data.duplicates || []
}
} catch (err) {
console.error('重复检查失败:', err)
dupResults.value = []
} finally {
checkingDup.value = false
}
}
async function handleMerge(duplicateId: string) {
if (!editingSuggestion.value) return
try {
const res = await fetch(
`/api/admin/knowledge-iteration/suggestions/${editingSuggestion.value.id}/merge`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duplicate_id: duplicateId }),
},
)
const json = await res.json()
if (json.code === 0) {
showDupDialog.value = false
await Promise.all([fetchSuggestions(), fetchStats()])
}
} catch (err) {
console.error('合并失败:', err)
}
}
function dupTypeLabel(type: string): string {
const map: Record<string, string> = {
same_issue: '同名Issue',
similar_issue: '相似Issue',
title_similar: '标题相似',
}
return map[type] || type
}
// ===========================================================================
// 生命周期
// ===========================================================================
onMounted(() => {
fetchSuggestions()
fetchStats()
})
onUnmounted(() => {
if (graphChart) {
graphChart.dispose()
graphChart = null
}
})
</script>
<style scoped>
.knowledge-iteration-page {
padding: 20px;
}
.knowledge-iteration-page__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.knowledge-iteration-page__header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.knowledge-iteration-page__header-actions {
display: flex;
gap: 8px;
}
/* 统计栏 */
.knowledge-iteration-page__stats-bar {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.knowledge-iteration-page__stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px 20px;
background: #f5f7fa;
border-radius: 8px;
min-width: 80px;
}
.knowledge-iteration-page__stat-value {
font-size: 24px;
font-weight: 700;
color: #303133;
}
.knowledge-iteration-page__stat-label {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
/* 筛选栏 */
.knowledge-iteration-page__filters {
display: flex;
gap: 10px;
margin-bottom: 16px;
align-items: center;
flex-wrap: wrap;
}
/* 表格 */
.knowledge-iteration-page__table {
background: #ffffff;
border-radius: 8px;
overflow: hidden;
}
/* 分页 */
.knowledge-iteration-page__pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
/* ── 图谱视图(任务2:P2) ── */
.knowledge-iteration-page__graph-filters {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 16px;
}
.knowledge-iteration-page__graph-info {
font-size: 13px;
color: #909399;
}
.knowledge-iteration-page__graph-container {
width: 100%;
height: 550px;
background: #ffffff;
border-radius: 8px;
border: 1px solid #e4e7ed;
}
</style>
@@ -222,10 +222,7 @@ async function loadMatrix() {
try {
const resp = await apiClient.get('/admin/roles/permissions/matrix')
// 拦截器已统一返回 inner dataScheme A),失败会 reject,故成功即有效数据
matrixData.value = resp
} else {
ElMessage.error('拉取权限矩阵失败')
}
matrixData.value = resp as any
} catch (e: any) {
console.error('loadMatrix 失败:', e)
ElMessage.error('加载失败: ' + (e?.message || '未知错误'))
@@ -0,0 +1,417 @@
<!-- =============================================================================
企微IT智能服务台 管理后台 RAGFlow 文档上传页Tier1 / P1-5 / 通道 C
=============================================================================
说明训练师上传非标准格式文档到 RAGFlow 进行 ETL 处理
拖拽上传 + 处理进度 + 生成提案预览
P1-5 硬约束
- 训练师手动上传触发非定时扫描
- 支持格式.docx/.pdf/.txt/.png/.jpg
- source_type=document_ragflow
- 产出走 D7 审批流
============================================================================= -->
<template>
<div class="ragflow-ingestion-page">
<!-- 页面标题 -->
<div class="ragflow-ingestion-page__header">
<h2>📄 文档知识导入RAGFlow 通道 C</h2>
</div>
<!-- 说明 -->
<el-alert
title="通过 RAGFlow 将非标准格式文档整理为结构化知识片段,生成待审提案进入审批队列。"
type="info"
:closable="false"
show-icon
class="ragflow-ingestion-page__alert"
/>
<!-- 上传区域 -->
<div
class="ragflow-ingestion-page__upload"
:class="{ 'ragflow-ingestion-page__upload--dragover': isDragover }"
@dragenter.prevent="isDragover = true"
@dragover.prevent="isDragover = true"
@dragleave.prevent="isDragover = false"
@drop.prevent="handleDrop"
>
<div class="ragflow-ingestion-page__upload-content">
<el-icon :size="48" color="#909399">
<UploadFilled />
</el-icon>
<p class="ragflow-ingestion-page__upload-text">
拖拽文件到此处或点击下方按钮选择
</p>
<p class="ragflow-ingestion-page__upload-hint">
支持 .docx / .pdf / .txt / .png / .jpg最大 20MB
</p>
<!-- 分类提示选择 -->
<div class="ragflow-ingestion-page__category-select">
<span>分类提示</span>
<el-select v-model="categoryHint" size="default" style="width: 140px">
<el-option label="硬件" value="硬件" />
<el-option label="软件" value="软件" />
<el-option label="网络" value="网络" />
<el-option label="安全" value="安全" />
<el-option label="账号" value="账号" />
<el-option label="其他" value="其他" />
</el-select>
</div>
<!-- 选择文件按钮 -->
<el-button
type="primary"
:icon="'Upload'"
size="default"
:loading="uploading"
class="ragflow-ingestion-page__upload-btn"
@click="openFilePicker"
>
选择文件并上传
</el-button>
</div>
<!-- 隐藏的文件选择器 -->
<input
ref="fileInput"
type="file"
:accept="allowedExtensions.join(',')"
class="ragflow-ingestion-page__file-input"
@change="onFileSelected"
/>
</div>
<!-- 上传进度与结果 -->
<div v-if="uploadResult" class="ragflow-ingestion-page__result">
<el-card>
<template #header>
<span>处理结果</span>
</template>
<div class="ragflow-ingestion-page__result-info">
<el-tag :type="uploadResult.status === 'completed' ? 'success' : 'warning'" size="default">
{{ statusLabel(uploadResult.status) }}
</el-tag>
<span>文件: {{ uploadResult.file_name }}</span>
<span>生成提案: {{ uploadResult.suggestions_count }} </span>
</div>
<!-- 提案预览 -->
<div v-if="uploadResult.suggestions && uploadResult.suggestions.length > 0" class="ragflow-ingestion-page__suggestions">
<div
v-for="(sug, idx) in uploadResult.suggestions"
:key="idx"
class="ragflow-ingestion-page__suggestion-item"
>
<div class="ragflow-ingestion-page__suggestion-header">
<span>{{ sug.title }}</span>
<el-tag size="small" type="info">置信度 {{ Math.round(sug.confidence * 100) }}%</el-tag>
</div>
<div class="ragflow-ingestion-page__suggestion-category">
分类: {{ sug.category }}
</div>
</div>
</div>
<div class="ragflow-ingestion-page__result-note">
生成的提案已进入审批队列请前往
<router-link to="/knowledge-iteration">知识迭代管理</router-link>
审批
</div>
</el-card>
</div>
<!-- 处理日志 -->
<div v-if="processingLogs.length > 0" class="ragflow-ingestion-page__logs">
<el-card>
<template #header>
<span>处理日志</span>
</template>
<div
v-for="(log, idx) in processingLogs"
:key="idx"
class="ragflow-ingestion-page__log-item"
:class="`ragflow-ingestion-page__log-item--${log.level}`"
>
<span class="ragflow-ingestion-page__log-time">{{ log.time }}</span>
<span>{{ log.message }}</span>
</div>
</el-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { UploadFilled } from '@element-plus/icons-vue'
// ===========================================================================
// 常量
// ===========================================================================
const allowedExtensions = ['.docx', '.pdf', '.txt', '.png', '.jpg', '.jpeg']
const allowedMimeTypes = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/pdf',
'text/plain',
'image/png',
'image/jpeg',
]
const maxFileSize = 20 * 1024 * 1024
// ===========================================================================
// 状态
// ===========================================================================
const fileInput = ref<HTMLInputElement>()
const isDragover = ref(false)
const uploading = ref(false)
const categoryHint = ref('其他')
const uploadResult = ref<{
task_id: string
status: string
file_name: string
suggestions_count: number
suggestions: Array<{ title: string; category: string; confidence: number }>
} | null>(null)
const processingLogs = ref<Array<{ level: string; time: string; message: string }>>([])
// ===========================================================================
// 方法
// ===========================================================================
function openFilePicker() {
fileInput.value?.click()
}
function addLog(level: string, message: string) {
processingLogs.value.push({
level,
time: new Date().toLocaleTimeString(),
message,
})
}
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploadFile(file)
}
function handleDrop(event: DragEvent) {
isDragover.value = false
const file = event.dataTransfer?.files?.[0]
if (!file) return
uploadFile(file)
}
async function uploadFile(file: File) {
// 校验扩展名
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (!allowedExtensions.includes(ext)) {
addLog('error', `不支持的文件格式: ${ext}`)
return
}
// 校验大小
if (file.size > maxFileSize) {
addLog('error', `文件过大: ${(file.size / 1024 / 1024).toFixed(1)}MB(最大 20MB`)
return
}
uploading.value = true
addLog('info', `开始上传: ${file.name}${(file.size / 1024).toFixed(1)}KB`)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('category_hint', categoryHint.value)
addLog('info', '上传至 RAGFlow,等待 ETL 处理...')
const res = await fetch('/api/ragflow/ingest', {
method: 'POST',
body: formData,
})
const json = await res.json()
if (json.code === 0 && json.data) {
uploadResult.value = json.data
addLog('success', `处理完成!生成 ${json.data.suggestions_count} 条待审提案`)
} else {
addLog('error', `处理失败: ${json.message || '未知错误'}`)
}
} catch (err: any) {
addLog('error', `上传异常: ${err.message}`)
} finally {
uploading.value = false
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
completed: '处理完成',
failed: '处理失败',
pending: '处理中',
disabled: '服务未启用',
timeout: '处理超时',
}
return map[status] || status
}
</script>
<style scoped>
.ragflow-ingestion-page {
padding: 20px;
max-width: 800px;
}
.ragflow-ingestion-page__header h2 {
margin: 0 0 16px;
font-size: 20px;
font-weight: 600;
}
.ragflow-ingestion-page__alert {
margin-bottom: 16px;
}
/* 上传区域 */
.ragflow-ingestion-page__upload {
border: 2px dashed #dcdfe6;
border-radius: 12px;
padding: 40px;
text-align: center;
background: #fafafa;
transition: all 0.3s;
margin-bottom: 20px;
}
.ragflow-ingestion-page__upload--dragover {
border-color: #409eff;
background: #ecf5ff;
}
.ragflow-ingestion-page__upload-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ragflow-ingestion-page__upload-text {
font-size: 14px;
color: #606266;
margin: 0;
}
.ragflow-ingestion-page__upload-hint {
font-size: 12px;
color: #c0c4cc;
margin: 0;
}
.ragflow-ingestion-page__category-select {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #606266;
}
.ragflow-ingestion-page__upload-btn {
margin-top: 8px;
}
.ragflow-ingestion-page__file-input {
display: none;
}
/* 处理结果 */
.ragflow-ingestion-page__result {
margin-bottom: 20px;
}
.ragflow-ingestion-page__result-info {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 12px;
font-size: 13px;
}
.ragflow-ingestion-page__suggestions {
margin-bottom: 12px;
}
.ragflow-ingestion-page__suggestion-item {
padding: 8px 12px;
background: #f5f7fa;
border-radius: 6px;
margin-bottom: 6px;
}
.ragflow-ingestion-page__suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
font-size: 13px;
font-weight: 500;
}
.ragflow-ingestion-page__suggestion-category {
font-size: 12px;
color: #909399;
}
.ragflow-ingestion-page__result-note {
margin-top: 12px;
padding: 8px 12px;
background: #f0f9eb;
border-radius: 4px;
font-size: 13px;
color: #67c23a;
}
.ragflow-ingestion-page__result-note a {
color: #409eff;
text-decoration: underline;
}
/* 处理日志 */
.ragflow-ingestion-page__logs {
margin-bottom: 20px;
}
.ragflow-ingestion-page__log-item {
padding: 4px 0;
font-size: 12px;
border-bottom: 1px solid #f5f5f5;
}
.ragflow-ingestion-page__log-time {
color: #c0c4cc;
margin-right: 8px;
}
.ragflow-ingestion-page__log-item--info {
color: #909399;
}
.ragflow-ingestion-page__log-item--success {
color: #67c23a;
}
.ragflow-ingestion-page__log-item--error {
color: #f56c6c;
}
</style>
+29 -9
View File
@@ -415,10 +415,13 @@ onMounted(async () => {
getRoleMappingRules(),
getUserRoleAssignments(),
])
roles.value = rolesRes.data.data
mappingRules.value = rulesRes.data.data
userRoles.value = userRolesRes.data.data || []
} catch {
// apiClient 响应拦截器已解包:返回的是后端 data 字段(数组本身),
// 不再是 { code, data, message } 包裹结构。直接取数组,避免 .data.data 报错。
roles.value = unwrapList<Role>(rolesRes)
mappingRules.value = unwrapList<RoleMappingRule>(rulesRes)
userRoles.value = unwrapList<UserRole>(userRolesRes)
} catch (e) {
console.error('[Roles] 加载角色管理数据失败:', e)
roles.value = getDefaultRoles()
mappingRules.value = getDefaultMappingRules()
} finally {
@@ -532,6 +535,22 @@ function isDefaultRole(roleName: string): boolean {
return roles.value.some(r => r.name === roleName && r.is_default)
}
/**
* 安全解包列表型接口返回。
* apiClient 响应拦截器已解包,成功时返回的是后端 data 字段(数组本身);
* 这里做防御性处理,兼容「数组 / {data: 数组} / {data:{data: 数组}}」三种形态,
* 避免历史包裹结构导致的 .data.data 取数报错(曾导致用户角色分配表空白)。
*/
function unwrapList<T>(resp: unknown): T[] {
if (Array.isArray(resp)) return resp as T[]
const r = resp as { data?: unknown }
if (r && Array.isArray(r.data)) return r.data as T[]
if (r && r.data && typeof r.data === 'object' && Array.isArray((r.data as { data?: unknown }).data)) {
return (r.data as { data: T[] }).data
}
return []
}
// ==========================================================================
// 操作处理
// ==========================================================================
@@ -664,11 +683,12 @@ async function loadData(): Promise<void> {
getRoleMappingRules(),
getUserRoleAssignments(),
])
roles.value = rolesRes.data.data
mappingRules.value = rulesRes.data.data
userRoles.value = userRolesRes.data.data || []
} catch {
// 静默失败,保留现有数据
roles.value = unwrapList<Role>(rolesRes)
mappingRules.value = unwrapList<RoleMappingRule>(rulesRes)
userRoles.value = unwrapList<UserRole>(userRolesRes)
} catch (e) {
console.error('[Roles] 刷新角色管理数据失败:', e)
// 保留现有数据
}
}
</script>