提交 09ae4ab5 authored 作者: 陈泽健's avatar 陈泽健

feat(offline-mode): 问题排查双模式界面 - 离线模式产品树+资料下载+常见问题

新增问题排查模块双模式界面,根据 OFFLINE_MODE 自动切换:
- 在线模式:保持原有 AI 分析界面
- 离线模式:左侧产品树 + 右侧资料/FAQ 面板

后端:
- 新增 products.json 配置文件(9 个产品,28 份文档)
- 新增 product_service.py 产品服务模块
- 新增 5 个产品 API 端点
- SearchEngine 新增 search_by_product() 方法

前端:
- Index.vue 重写为模式切换入口
- 新增 OfflineIndex.vue 离线模式主布局
- 新增 ProductTree/DocumentsPanel/FAQsPanel 组件
- 新增 Product/Document/FAQ 类型定义

部署:
- Dockerfile 新增维护手册目录复制
- deploy_docker.py 支持中文文件名上传
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 a3b7ab19
...@@ -36,6 +36,9 @@ COPY skill/SKILL.md /app/SKILL.md ...@@ -36,6 +36,9 @@ COPY skill/SKILL.md /app/SKILL.md
# ---------- Vue 前端构建产物(本地 npm run build 后直接复制) ---------- # ---------- Vue 前端构建产物(本地 npm run build 后直接复制) ----------
COPY frontend/dist/ /data/dist/ COPY frontend/dist/ /data/dist/
# ---------- 产品维护手册(离线模式文档下载) ----------
COPY Docs/维护手册/ /app/Docs/维护手册/
# ---------- 运行时数据目录(volume 挂载点) ---------- # ---------- 运行时数据目录(volume 挂载点) ----------
RUN mkdir -p /app/data \ RUN mkdir -p /app/data \
&& mkdir -p /app/web/service_monitor/data/reports \ && mkdir -p /app/web/service_monitor/data/reports \
......
此差异已折叠。
此差异已折叠。
此差异已折叠。
...@@ -32,6 +32,7 @@ DIRS_TO_UPLOAD = [ ...@@ -32,6 +32,7 @@ DIRS_TO_UPLOAD = [
('frontend/dist', 'frontend/dist'), ('frontend/dist', 'frontend/dist'),
('nginx', 'nginx'), ('nginx', 'nginx'),
('config', 'config'), ('config', 'config'),
('Docs/维护手册', 'Docs/维护手册'), # 产品维护手册(离线模式下载)
] ]
# 需要上传的单个文件 # 需要上传的单个文件
...@@ -48,24 +49,32 @@ _EXCLUDE_SUFFIXES = ('.pyc', '.pyo', '.log') ...@@ -48,24 +49,32 @@ _EXCLUDE_SUFFIXES = ('.pyc', '.pyo', '.log')
def _upload_dir_recursive(sftp, ssh, local_dir, remote_dir): def _upload_dir_recursive(sftp, ssh, local_dir, remote_dir):
"""递归上传目录""" """递归上传目录(使用 pathlib 处理中文文件名)"""
from pathlib import Path
local_path = Path(local_dir)
# 确保远程目录存在 # 确保远程目录存在
ssh.exec_command(f'mkdir -p "{remote_dir}"')[1].channel.recv_exit_status() ssh.exec_command(f'mkdir -p "{remote_dir}"')[1].channel.recv_exit_status()
for fname in os.listdir(local_dir): for child in sorted(local_path.iterdir()):
fname = child.name
if fname in _EXCLUDE_DIRS: if fname in _EXCLUDE_DIRS:
continue continue
if fname.endswith(_EXCLUDE_SUFFIXES): if fname.endswith(_EXCLUDE_SUFFIXES):
continue continue
lpath = os.path.join(local_dir, fname)
rpath = remote_dir + "/" + fname rpath = remote_dir + "/" + fname
if os.path.isfile(lpath): if child.is_file():
print(f" Upload: {os.path.relpath(lpath, REPO_ROOT)}") try:
sftp.put(lpath, rpath) rel = os.path.relpath(str(child), REPO_ROOT)
elif os.path.isdir(lpath): print(f" Upload: {rel}")
_upload_dir_recursive(sftp, ssh, lpath, rpath) sftp.put(str(child), rpath)
except (PermissionError, OSError) as e:
print(f" [SKIP] {rel} ({e})")
elif child.is_dir():
_upload_dir_recursive(sftp, ssh, str(child), rpath)
def deploy(): def deploy():
......
...@@ -17,6 +17,10 @@ import type { ...@@ -17,6 +17,10 @@ import type {
SubmitRequest, SubmitRequest,
SubmitResponse, SubmitResponse,
AnalyzeStreamEvent, AnalyzeStreamEvent,
ProductsResponse,
ProductResponse,
DocumentsResponse,
FAQsResponse,
} from '@/types/troubleshoot' } from '@/types/troubleshoot'
import type { ProjectsResponse, CategoriesResponse } from '@/types/api' import type { ProjectsResponse, CategoriesResponse } from '@/types/api'
...@@ -100,4 +104,38 @@ export async function exportReport(data: ExportRequest): Promise<Blob> { ...@@ -100,4 +104,38 @@ export async function exportReport(data: ExportRequest): Promise<Blob> {
export async function submitRecord(data: SubmitRequest): Promise<SubmitResponse> { export async function submitRecord(data: SubmitRequest): Promise<SubmitResponse> {
const res = await http.post<SubmitResponse>('/api/submit', data) const res = await http.post<SubmitResponse>('/api/submit', data)
return res.data return res.data
}
// ============================================================
// 产品相关 API(离线模式)
// ============================================================
/** 获取产品列表 */
export async function getProducts(): Promise<ProductsResponse> {
const res = await http.get('/api/troubleshoot/products')
return res.data
}
/** 获取产品详情 */
export async function getProduct(productId: string): Promise<ProductResponse> {
const res = await http.get(`/api/troubleshoot/products/${productId}`)
return res.data
}
/** 获取产品资料列表 */
export async function getProductDocuments(productId: string): Promise<DocumentsResponse> {
const res = await http.get(`/api/troubleshoot/products/${productId}/documents`)
return res.data
}
/** 下载文档 */
export function downloadDocument(docId: string): string {
return `/api/troubleshoot/documents/${docId}/download`
}
/** 获取产品常见问题 */
export async function getProductFAQs(productId: string, limit?: number): Promise<FAQsResponse> {
const params = limit ? { limit } : {}
const res = await http.get(`/api/troubleshoot/products/${productId}/faqs`, { params })
return res.data
} }
\ No newline at end of file
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getProductDocuments, downloadDocument, getProduct } from '@/api/troubleshoot'
import type { ProductDocument, Product } from '@/types/troubleshoot'
import { ElMessage } from 'element-plus'
interface Props {
productId: string
productName: string
}
const props = defineProps<Props>()
const product = ref<Product | null>(null)
const documents = ref<ProductDocument[]>([])
const loading = ref(true)
async function loadData() {
loading.value = true
try {
const [productRes, docsRes] = await Promise.all([
getProduct(props.productId),
getProductDocuments(props.productId),
])
if (productRes.success) {
product.value = productRes.product || null
}
if (docsRes.success) {
documents.value = docsRes.documents
}
} catch {
ElMessage.error('加载资料失败')
} finally {
loading.value = false
}
}
function handleDownload(doc: ProductDocument) {
const url = downloadDocument(doc.id)
window.open(url, '_blank')
ElMessage.success(`正在下载:${doc.title}`)
}
function getFileIcon(type: string): string {
switch (type.toLowerCase()) {
case 'pdf': return '📕'
case 'docx':
case 'doc': return '📘'
case 'xlsx':
case 'xls': return '📗'
case 'md': return '📝'
default: return '📄'
}
}
function getTypeColor(type: string): string {
switch (type.toLowerCase()) {
case 'pdf': return '#cf1322'
case 'docx':
case 'doc': return '#1677ff'
case 'xlsx':
case 'xls': return '#389e0d'
case 'md': return '#531dab'
default: return '#86909c'
}
}
watch(() => props.productId, loadData, { immediate: true })
</script>
<template>
<div class="documents-panel" v-loading="loading">
<!-- 标题 -->
<div class="panel-header">
<div class="header-left">
<h2>📁 {{ productName }} — 产品资料</h2>
</div>
<span class="doc-count">{{ documents.length }} 份文档</span>
</div>
<!-- 空状态 -->
<div v-if="!loading && documents.length === 0" class="empty-state">
<div class="empty-icon">📂</div>
<h3>暂无资料文档</h3>
<p>该产品暂未上传维护手册或技术文档</p>
</div>
<!-- 文档列表 -->
<div v-else class="doc-list">
<div
v-for="doc in documents"
:key="doc.id"
class="doc-item"
>
<div class="doc-icon-wrap">
<span class="doc-icon">{{ getFileIcon(doc.type) }}</span>
</div>
<div class="doc-info">
<div class="doc-title" @click="handleDownload(doc)">
{{ doc.title }}
</div>
<div class="doc-meta">
<span
class="doc-type"
:style="{ color: getTypeColor(doc.type), background: getTypeColor(doc.type) + '12' }"
>
{{ doc.type.toUpperCase() }}
</span>
<span v-if="doc.size" class="doc-size">{{ doc.size }}</span>
</div>
</div>
<el-button type="primary" size="small" @click="handleDownload(doc)">
<span class="btn-icon"></span>
下载
</el-button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.documents-panel {
background: #fff;
border-radius: 12px;
padding: 24px;
min-height: 400px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
h2 {
font-size: 18px;
font-weight: 600;
color: #1d2129;
margin: 0;
}
.doc-count {
font-size: 13px;
color: #86909c;
background: #f2f3f5;
padding: 4px 12px;
border-radius: 12px;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: #86909c;
.empty-icon {
font-size: 56px;
margin-bottom: 16px;
opacity: 0.5;
}
h3 {
font-size: 16px;
font-weight: 500;
color: #4e5969;
margin: 0 0 8px;
}
p {
font-size: 14px;
margin: 0;
}
}
.doc-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.doc-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: #fafafa;
border-radius: 10px;
border: 1px solid #f0f0f0;
transition: all 0.2s;
&:hover {
background: #f7f9fc;
border-color: #e0e7ff;
box-shadow: 0 2px 8px rgba(22, 125, 255, 0.08);
}
}
.doc-icon-wrap {
flex-shrink: 0;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 10px;
border: 1px solid #f0f0f0;
}
.doc-icon {
font-size: 28px;
}
.doc-info {
flex: 1;
min-width: 0;
}
.doc-title {
font-size: 15px;
font-weight: 500;
color: #1d2129;
margin-bottom: 6px;
cursor: pointer;
&:hover {
color: #165dff;
}
}
.doc-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.doc-type {
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.5px;
}
.doc-size {
color: #86909c;
}
.btn-icon {
margin-right: 4px;
}
</style>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getProductFAQs, getProduct } from '@/api/troubleshoot'
import type { FAQ, Product } from '@/types/troubleshoot'
import { renderMarkdown } from '@/utils/markdown'
import { ElMessage } from 'element-plus'
interface Props {
productId: string
productName: string
}
const props = defineProps<Props>()
const product = ref<Product | null>(null)
const faqs = ref<FAQ[]>([])
const loading = ref(true)
// 当前展开的问题
const expandedFAQ = ref<string | null>(null)
const faqDetail = ref<string>('')
const faqDetailLoading = ref(false)
async function loadData() {
loading.value = true
expandedFAQ.value = null
faqDetail.value = ''
try {
const [productRes, faqsRes] = await Promise.all([
getProduct(props.productId),
getProductFAQs(props.productId),
])
if (productRes.success) {
product.value = productRes.product || null
}
if (faqsRes.success) {
faqs.value = faqsRes.faqs
}
} catch {
ElMessage.error('加载常见问题失败')
} finally {
loading.value = false
}
}
async function toggleFAQ(faq: FAQ) {
if (expandedFAQ.value === faq.record_id) {
expandedFAQ.value = null
return
}
expandedFAQ.value = faq.record_id
faqDetail.value = ''
faqDetailLoading.value = true
try {
// 静态 Mock:直接使用 solution 字段作为详情
faqDetail.value = faq.solution
// TODO: 真实 API 调用
// const res = await http.get(`/api/troubleshoot/record/${faq.record_id}`)
// if (res.data?.success && res.data.record) {
// faqDetail.value = res.data.record.full_text || faq.solution
// }
} catch {
faqDetail.value = faq.solution || '加载失败'
} finally {
faqDetailLoading.value = false
}
}
function downloadFAQRecord(faq: FAQ) {
window.open(`/api/troubleshoot/record/${faq.record_id}/download`, '_blank')
}
watch(() => props.productId, loadData, { immediate: true })
</script>
<template>
<div class="faqs-panel" v-loading="loading">
<!-- 标题 -->
<div class="panel-header">
<div class="header-left">
<h2>{{ productName }} — 常见问题</h2>
</div>
<span class="faq-count">{{ faqs.length }} 个问题</span>
</div>
<!-- 空状态 -->
<div v-if="!loading && faqs.length === 0" class="empty-state">
<div class="empty-icon">🔍</div>
<h3>暂无常见问题</h3>
<p>该产品暂未录入常见问题与排查指引</p>
</div>
<!-- 问题列表 -->
<div v-else class="faq-list">
<div
v-for="(faq, index) in faqs"
:key="faq.record_id"
class="faq-item"
:class="{ expanded: expandedFAQ === faq.record_id }"
>
<!-- 问题标题行 -->
<div class="faq-header" @click="toggleFAQ(faq)">
<span class="faq-index">{{ index + 1 }}</span>
<span class="faq-title">{{ faq.title }}</span>
<span class="expand-icon" :class="{ expanded: expandedFAQ === faq.record_id }">
</span>
</div>
<!-- 问题摘要(未展开时) -->
<div v-if="expandedFAQ !== faq.record_id" class="faq-summary" @click="toggleFAQ(faq)">
{{ faq.phenomenon?.slice(0, 80) }}<span v-if="faq.phenomenon && faq.phenomenon.length > 80">...</span>
</div>
<!-- 问题详情(展开时) -->
<transition name="expand">
<div v-if="expandedFAQ === faq.record_id" class="faq-detail" v-loading="faqDetailLoading">
<!-- 问题现象 -->
<div class="detail-section">
<div class="section-label">
<span class="section-icon">🔴</span>
问题现象
</div>
<div class="section-content phenomenon">
{{ faq.phenomenon }}
</div>
</div>
<!-- 排查指引 -->
<div class="detail-section">
<div class="section-label">
<span class="section-icon">🟢</span>
排查指引
</div>
<div class="section-content">
<div
v-if="faqDetail"
class="markdown-content"
v-html="renderMarkdown(faqDetail)"
/>
<p v-else class="no-content">暂无详细内容</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="detail-actions">
<el-button size="small" @click="downloadFAQRecord(faq)">
📥 下载文档
</el-button>
</div>
</div>
</transition>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.faqs-panel {
background: #fff;
border-radius: 12px;
padding: 24px;
min-height: 400px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
h2 {
font-size: 18px;
font-weight: 600;
color: #1d2129;
margin: 0;
}
.faq-count {
font-size: 13px;
color: #86909c;
background: #f2f3f5;
padding: 4px 12px;
border-radius: 12px;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: #86909c;
.empty-icon {
font-size: 56px;
margin-bottom: 16px;
opacity: 0.5;
}
h3 {
font-size: 16px;
font-weight: 500;
color: #4e5969;
margin: 0 0 8px;
}
p {
font-size: 14px;
margin: 0;
}
}
.faq-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.faq-item {
background: #fafafa;
border-radius: 10px;
border: 1px solid #f0f0f0;
overflow: hidden;
transition: all 0.2s;
&.expanded {
background: #fff;
border-color: #e0e7ff;
box-shadow: 0 4px 16px rgba(22, 125, 255, 0.1);
}
}
.faq-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #f2f3f5;
}
.expanded & {
background: #f7f9fc;
}
}
.faq-index {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #e8f3ff;
color: #165dff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.faq-title {
flex: 1;
font-size: 14px;
font-weight: 500;
color: #1d2129;
line-height: 1.5;
}
.expand-icon {
font-size: 10px;
color: #86909c;
transition: transform 0.2s;
flex-shrink: 0;
&.expanded {
transform: rotate(90deg);
}
}
.faq-summary {
padding: 0 18px 14px 54px;
font-size: 13px;
color: #86909c;
line-height: 1.6;
cursor: pointer;
&:hover {
color: #4e5969;
}
}
.faq-detail {
padding: 0 18px 18px;
border-top: 1px solid #f0f0f0;
margin-top: 0;
}
.detail-section {
margin-top: 16px;
}
.section-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #1d2129;
margin-bottom: 10px;
.section-icon {
font-size: 12px;
}
}
.section-content {
background: #f7f8fa;
border-radius: 8px;
padding: 14px 16px;
font-size: 14px;
color: #4e5969;
line-height: 1.8;
&.phenomenon {
border-left: 3px solid #ff7d00;
}
}
.markdown-content {
line-height: 1.8;
:deep(ol), :deep(ul) {
padding-left: 20px;
margin: 8px 0;
}
:deep(li) {
margin-bottom: 4px;
}
:deep(code) {
background: #e8f3ff;
color: #165dff;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
:deep(pre) {
background: #1d2129;
color: #e5e6eb;
padding: 12px 16px;
border-radius: 8px;
overflow-x: auto;
margin: 12px 0;
code {
background: none;
color: inherit;
padding: 0;
}
}
}
.no-content {
color: #c9cdd4;
font-style: italic;
margin: 0;
}
.detail-actions {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
}
// 展开动画
.expand-enter-active,
.expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
.expand-enter-to,
.expand-leave-from {
opacity: 1;
max-height: 800px;
}
</style>
<script setup lang="ts">
import { ref } from 'vue'
import type { Product } from '@/types/troubleshoot'
interface Props {
products: Product[]
loading?: boolean
selectedProductId?: string | null
selectedNodeType?: 'documents' | 'faqs' | null
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
selectedProductId: null,
selectedNodeType: null,
})
const emit = defineEmits<{
select: [productId: string, productName: string, nodeType: 'documents' | 'faqs']
}>()
// 展开状态
const expandedProducts = ref<Set<string>>(new Set())
function toggleProduct(productId: string) {
const newSet = new Set(expandedProducts.value)
if (newSet.has(productId)) {
newSet.delete(productId)
} else {
newSet.add(productId)
}
expandedProducts.value = newSet
}
function selectNode(product: Product, nodeType: 'documents' | 'faqs') {
emit('select', product.id, product.name, nodeType)
}
function isNodeActive(productId: string, nodeType: 'documents' | 'faqs'): boolean {
return props.selectedProductId === productId && props.selectedNodeType === nodeType
}
function getProductIcon(product: Product): string {
const name = product.name.toLowerCase()
if (name.includes('门口屏')) return '🖥️'
if (name.includes('桌牌')) return '🏷️'
if (name.includes('无纸化')) return '📱'
if (name.includes('预定')) return '📅'
if (name.includes('会管')) return '👥'
if (name.includes('运维')) return '🔧'
return '📦'
}
</script>
<template>
<div class="product-tree" v-loading="loading">
<div v-if="products.length === 0 && !loading" class="empty">
<span class="empty-icon">🔍</span>
<p>未找到匹配的产品</p>
</div>
<div v-else class="tree-list">
<div
v-for="product in products"
:key="product.id"
class="product-node"
>
<!-- 产品节点 -->
<div
class="product-header"
@click="toggleProduct(product.id)"
>
<span class="expand-icon" :class="{ expanded: expandedProducts.has(product.id) }">
</span>
<span class="product-icon">{{ getProductIcon(product) }}</span>
<span class="product-name">{{ product.name }}</span>
</div>
<!-- 子节点 -->
<transition name="slide">
<div
v-show="expandedProducts.has(product.id)"
class="product-children"
>
<div
class="child-node"
:class="{ active: isNodeActive(product.id, 'documents') }"
@click.stop="selectNode(product, 'documents')"
>
<span class="child-icon">📁</span>
<span class="child-label">产品资料</span>
<span v-if="product.documents?.length" class="count-badge">
{{ product.documents.length }}
</span>
</div>
<div
class="child-node"
:class="{ active: isNodeActive(product.id, 'faqs') }"
@click.stop="selectNode(product, 'faqs')"
>
<span class="child-icon"></span>
<span class="child-label">常见问题</span>
<span v-if="product.faq_count" class="count-badge">
{{ product.faq_count }}
</span>
</div>
</div>
</transition>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.product-tree {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #86909c;
.empty-icon {
font-size: 32px;
margin-bottom: 8px;
}
p {
font-size: 13px;
margin: 0;
}
}
.tree-list {
padding: 0 8px;
}
.product-node {
margin-bottom: 2px;
}
.product-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
cursor: pointer;
border-radius: 8px;
transition: background 0.2s;
&:hover {
background: #f2f3f5;
}
}
.expand-icon {
font-size: 10px;
color: #86909c;
width: 14px;
text-align: center;
transition: transform 0.2s;
flex-shrink: 0;
&.expanded {
transform: rotate(90deg);
}
}
.product-icon {
font-size: 20px;
flex-shrink: 0;
}
.product-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: #1d2129;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-children {
margin-left: 24px;
padding: 2px 0 4px;
}
.child-node {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
color: #4e5969;
transition: all 0.2s;
margin-bottom: 1px;
&:hover {
background: #e8f3ff;
color: #165dff;
}
&.active {
background: #e8f3ff;
color: #165dff;
font-weight: 500;
}
}
.child-icon {
font-size: 16px;
flex-shrink: 0;
}
.child-label {
flex: 1;
}
.count-badge {
background: #f2f3f5;
color: #86909c;
font-size: 11px;
padding: 1px 6px;
border-radius: 8px;
min-width: 20px;
text-align: center;
.child-node.active & {
background: #bedaff;
color: #165dff;
}
}
// 展开动画
.slide-enter-active,
.slide-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
max-height: 0;
}
.slide-enter-to,
.slide-leave-from {
opacity: 1;
max-height: 200px;
}
</style>
...@@ -16,12 +16,13 @@ const routes: RouteRecordRaw[] = [ ...@@ -16,12 +16,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/Platform.vue'), component: () => import('@/views/Platform.vue'),
meta: { requiresAuth: true, title: '运行维护平台' } meta: { requiresAuth: true, title: '运行维护平台' }
}, },
// 问题排查助手 -- 独立布局(无侧边栏) // 问题排查 -- 独立布局(无侧边栏)
// Index.vue 会根据 /api/health 的 offline_mode 自动切换界面
{ {
path: '/troubleshoot', path: '/troubleshoot',
name: 'Troubleshoot', name: 'Troubleshoot',
component: () => import('@/views/troubleshoot/Index.vue'), component: () => import('@/views/troubleshoot/Index.vue'),
meta: { requiresAuth: true, title: '问题排查助手' } meta: { requiresAuth: true, title: '问题排查' }
}, },
{ {
path: '/troubleshoot/logs', path: '/troubleshoot/logs',
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
export interface MatchedCase { export interface MatchedCase {
rank: number rank: number
score: number score: number
record_id: string
project: string project: string
title: string title: string
file: string file: string
...@@ -153,3 +154,65 @@ export interface AnalyzeStreamEvent { ...@@ -153,3 +154,65 @@ export interface AnalyzeStreamEvent {
cached?: boolean cached?: boolean
offline?: boolean offline?: boolean
} }
// ============================================================
// 离线模式类型(产品资料 + 常见问题)
// ============================================================
/** 产品信息 */
export interface Product {
id: string
name: string
system_type?: string
apk_product?: string
documents?: ProductDocument[]
faq_count?: number
tags?: string[]
}
/** 产品文档资料 */
export interface ProductDocument {
id: string
title: string
filename: string
type: string
size?: string
}
/** 常见问题 */
export interface FAQ {
record_id: string
title: string
phenomenon: string
solution: string
score?: number
}
/** 产品列表响应 */
export interface ProductsResponse {
success: boolean
products: Product[]
total: number
}
/** 产品详情响应 */
export interface ProductResponse {
success: boolean
product?: Product
message?: string
}
/** 文档列表响应 */
export interface DocumentsResponse {
success: boolean
documents: ProductDocument[]
product_id: string
}
/** 常见问题列表响应 */
export interface FAQsResponse {
success: boolean
faqs: FAQ[]
product_id: string
total: number
}
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import AppNavbar from '@/components/layout/AppNavbar.vue'
import ProductTree from '@/components/troubleshoot/ProductTree.vue'
import DocumentsPanel from '@/components/troubleshoot/DocumentsPanel.vue'
import FAQsPanel from '@/components/troubleshoot/FAQsPanel.vue'
import { getProducts } from '@/api/troubleshoot'
import type { Product } from '@/types/troubleshoot'
import { ElMessage } from 'element-plus'
// 产品列表
const products = ref<Product[]>([])
const loading = ref(true)
// 当前选中
const selectedProductId = ref<string | null>(null)
const selectedProductName = ref('')
const selectedNodeType = ref<'documents' | 'faqs' | null>(null)
// 搜索关键词
const searchKeyword = ref('')
// 过滤后的产品列表
const filteredProducts = computed(() => {
if (!searchKeyword.value) return products.value
const keyword = searchKeyword.value.toLowerCase()
return products.value.filter(p =>
p.name.toLowerCase().includes(keyword) ||
p.tags?.some(t => t.toLowerCase().includes(keyword))
)
})
// 加载产品列表
async function loadProducts() {
try {
const res = await getProducts()
if (res.success) {
products.value = res.products
}
} catch (e) {
ElMessage.error('加载产品列表失败')
} finally {
loading.value = false
}
}
// 处理节点选择
function handleNodeSelect(productId: string, productName: string, nodeType: 'documents' | 'faqs') {
selectedProductId.value = productId
selectedProductName.value = productName
selectedNodeType.value = nodeType
}
onMounted(() => {
loadProducts()
})
</script>
<template>
<div class="page-offline-troubleshoot">
<AppNavbar
show-module
module-label="问题排查(离线)"
home-path="/troubleshoot"
logs-path="/troubleshoot/logs"
/>
<div class="main-container">
<!-- 左侧面板:产品树 -->
<aside class="left-panel">
<div class="panel-title">
<span class="title-icon">📦</span>
<span>产品列表</span>
</div>
<div class="search-box">
<el-input
v-model="searchKeyword"
placeholder="搜索产品..."
clearable
:prefix-icon="'Search'"
/>
</div>
<ProductTree
:products="filteredProducts"
:loading="loading"
:selected-product-id="selectedProductId"
:selected-node-type="selectedNodeType"
@select="handleNodeSelect"
/>
</aside>
<!-- 右侧面板:内容区 -->
<main class="right-panel">
<div v-if="!selectedProductId" class="empty-state">
<div class="empty-icon">📋</div>
<h3>请从左侧选择产品</h3>
<p>点击产品下的「产品资料」或「常见问题」查看详细内容</p>
</div>
<DocumentsPanel
v-else-if="selectedNodeType === 'documents'"
:product-id="selectedProductId"
:product-name="selectedProductName"
/>
<FAQsPanel
v-else-if="selectedNodeType === 'faqs'"
:product-id="selectedProductId"
:product-name="selectedProductName"
/>
</main>
</div>
</div>
</template>
<style scoped lang="scss">
.page-offline-troubleshoot {
min-height: 100vh;
background: #f0f2f5;
padding-top: 56px;
}
.main-container {
display: flex;
height: calc(100vh - 56px);
}
.left-panel {
width: 280px;
background: #fff;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
flex-shrink: 0;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04);
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px 12px;
font-size: 16px;
font-weight: 600;
color: #1d2129;
border-bottom: 1px solid #f0f0f0;
.title-icon {
font-size: 20px;
}
}
.search-box {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.right-panel {
flex: 1;
overflow-y: auto;
padding: 24px;
background: #f0f2f5;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #86909c;
.empty-icon {
font-size: 72px;
margin-bottom: 20px;
opacity: 0.6;
}
h3 {
font-size: 18px;
font-weight: 500;
color: #4e5969;
margin-bottom: 8px;
}
p {
font-size: 14px;
}
}
// ============================================================
// 响应式适配
// ============================================================
@media screen and (max-width: 768px) {
.main-container {
flex-direction: column;
}
.left-panel {
width: 100%;
height: auto;
max-height: 45vh;
border-right: none;
border-bottom: 1px solid #e8e8e8;
}
.right-panel {
padding: 16px;
}
}
</style>
此差异已折叠。
{
"products": [
{
"id": "door-screen",
"name": "门口屏",
"system_type": "标准版预定2.0",
"apk_product": "门口屏",
"documents": [
{ "id": "ds-001", "title": "门口屏5.0部署文档", "filename": "门口屏/01-门口屏5.0部署文档.docx", "type": "docx" },
{ "id": "ds-002", "title": "门口屏5.0使用说明书", "filename": "门口屏/02-门口屏5.0使用说明书.docx", "type": "docx" },
{ "id": "ds-003", "title": "门口屏5.0维护手册", "filename": "门口屏/03-门口屏5.0维护手册.docx", "type": "docx" },
{ "id": "ds-004", "title": "门口屏5.0第三方设备部署文档", "filename": "门口屏/04-门口屏5.0第三方设备部署文档.docx", "type": "docx" }
],
"tags": ["门口屏", "会议显示", "MQTT"]
},
{
"id": "paperless",
"name": "无纸化",
"system_type": "标准版预定2.0",
"apk_product": "无纸化",
"documents": [
{ "id": "pl-001", "title": "中控版本无纸化部署文档", "filename": "无纸化/01-中控版本无纸化部署文档.docx", "type": "docx" },
{ "id": "pl-002", "title": "会议秘书服务呼叫系统部署文档", "filename": "无纸化/01-会议秘书服务呼叫系统部署文档.docx", "type": "docx" },
{ "id": "pl-003", "title": "无纸化同屏大屏器部署文档", "filename": "无纸化/01-无纸化同屏大屏器部署文档.docx", "type": "docx" },
{ "id": "pl-004", "title": "中控版本无纸化使用说明书", "filename": "无纸化/02-中控版本无纸化使用说明书.docx", "type": "docx" },
{ "id": "pl-005", "title": "会议秘书服务呼叫系统使用文档", "filename": "无纸化/02-会议秘书服务呼叫系统使用文档.docx", "type": "docx" },
{ "id": "pl-006", "title": "无纸化公网使用注意事项", "filename": "无纸化/无纸化公网使用注意事项.docx", "type": "docx" }
],
"tags": ["无纸化", "会议平板", "座位编排"]
},
{
"id": "smart-screen-control",
"name": "智屏集控",
"system_type": "标准版运维集控系统",
"apk_product": "",
"documents": [
{ "id": "sc-001", "title": "UBAINS智屏集控部署文档", "filename": "智屏集控/01-UBAINS智屏集控部署文档.docx", "type": "docx" },
{ "id": "sc-002", "title": "UBAINS智屏集控操作说明文档", "filename": "智屏集控/02-UBAINS智屏集控操作说明文档.docx", "type": "docx" },
{ "id": "sc-003", "title": "UBAINS智屏集控维护手册", "filename": "智屏集控/03-UBAINS智屏集控维护手册.docx", "type": "docx" },
{ "id": "sc-004", "title": "UBAINS集控系统操作说明文档(冰点还原版)", "filename": "智屏集控/04-UBAINS集控系统操作说明文档(冰点还原版).docx", "type": "docx" },
{ "id": "sc-005", "title": "冰点还原软件部署文档", "filename": "智屏集控/04-冰点还原软件部署文档.docx", "type": "docx" }
],
"tags": ["智屏集控", "运维", "冰点还原"]
},
{
"id": "bt-base-station",
"name": "蓝牙基站",
"system_type": "标准版预定2.0",
"apk_product": "桌牌",
"documents": [
{ "id": "bt-001", "title": "蓝牙基座部署文档", "filename": "蓝牙基站/蓝牙基座部署文档.docx", "type": "docx" },
{ "id": "bt-002", "title": "蓝牙基站版桌牌系统操作手册", "filename": "蓝牙基站/蓝牙基站版桌牌系统操作手册(官方).pdf", "type": "pdf" },
{ "id": "bt-003", "title": "蓝牙基站版桌牌系统配置指导书", "filename": "蓝牙基站/蓝牙基站版桌牌系统配置指导书(官方).pdf", "type": "pdf" }
],
"tags": ["蓝牙基站", "桌牌", "定位"]
},
{
"id": "dual-screen",
"name": "双屏互动",
"system_type": "新统一平台",
"apk_product": "",
"documents": [
{ "id": "du-001", "title": "新双屏软件操作说明文档", "filename": "双屏互动/新双屏软件操作说明文档.docx", "type": "docx" },
{ "id": "du-002", "title": "新双屏软件维护手册", "filename": "双屏互动/新双屏软件维护手册.docx", "type": "docx" },
{ "id": "du-003", "title": "新双屏软件部署文档", "filename": "双屏互动/新双屏软件部署文档.docx", "type": "docx" }
],
"tags": ["双屏互动", "同屏", "协作"]
},
{
"id": "meeting-assistant",
"name": "会议助手",
"system_type": "标准版预定2.0",
"apk_product": "",
"documents": [
{ "id": "ma-001", "title": "会议助手部署及操作说明书", "filename": "会议助手/02-会议助手部署及操作说明书 .docx", "type": "docx" },
{ "id": "ma-002", "title": "会议助手维护手册", "filename": "会议助手/03-会议助手维护手册.docx", "type": "docx" }
],
"tags": ["会议助手", "语音", "秘书"]
},
{
"id": "voice-assistant",
"name": "语音助手",
"system_type": "标准版预定2.0",
"apk_product": "",
"documents": [
{ "id": "va-001", "title": "语音助手使用手册", "filename": "语音助手/2.语音助手使用手册.docx", "type": "docx" }
],
"tags": ["语音助手", "语音识别", "转写"]
},
{
"id": "ideatop-android",
"name": "IDEATOP-安卓",
"system_type": "新统一平台",
"apk_product": "IDEATOP",
"documents": [
{ "id": "ia-001", "title": "IdeaTop软件维护手册", "filename": "IDEATOP-安卓/IdeaTop软件维护手册V1.0.0.4.docx", "type": "docx" },
{ "id": "ia-002", "title": "IdeaTop软件配置&操作说明文档", "filename": "IDEATOP-安卓/IdeaTop软件配置&操作说明文档V1.0.0.4.docx", "type": "docx" }
],
"tags": ["IDEATOP", "安卓", "APP"]
},
{
"id": "ideatop-control",
"name": "IDEATOP-中控",
"system_type": "新统一平台",
"apk_product": "",
"documents": [
{ "id": "ic-001", "title": "IdeaTop软件配置&操作说明文档(中控版) 英文版", "filename": "IDEATOP-中控/IdeaTop软件配置&操作说明文档(中控版)V1.0.0.4_英文版.docx", "type": "docx" },
{ "id": "ic-002", "title": "IdeaTop软件配置&操作说明文档(中控版) 中文版", "filename": "IDEATOP-中控/IdeaTop软件配置&操作说明文档(中控版)V1.0.0.6_中文版.docx", "type": "docx" }
],
"tags": ["IDEATOP", "中控", "配置"]
}
]
}
\ No newline at end of file
...@@ -208,6 +208,24 @@ class SearchEngine: ...@@ -208,6 +208,24 @@ class SearchEngine:
print(f"[搜索引擎] 初始化完成:{len(self.records)} 条记录,搜索模式:{'vector' if self._embedding_enabled else 'tfidf'}") print(f"[搜索引擎] 初始化完成:{len(self.records)} 条记录,搜索模式:{'vector' if self._embedding_enabled else 'tfidf'}")
# ============================================================
# 按ID查询记录
# ============================================================
def get_record_by_id(self, record_id: str) -> dict | None:
"""按 ID 返回单条记录的完整数据。
Args:
record_id: 记录 ID(字符串,如 "1", "42")
Returns:
记录 dict(含 full_text),未找到返回 None
"""
for r in self.records:
if str(r.get('id', '')) == str(record_id):
return dict(r)
return None
# ============================================================ # ============================================================
# P2-1:向量搜索内部方法 # P2-1:向量搜索内部方法
# ============================================================ # ============================================================
...@@ -525,6 +543,68 @@ class SearchEngine: ...@@ -525,6 +543,68 @@ class SearchEngine:
"""获取所有分类列表""" """获取所有分类列表"""
return list(self.index.get('categories', {}).keys()) return list(self.index.get('categories', {}).keys())
def search_by_product(self, apk_product: str = '', tags: list[str] = None, limit: int = 50) -> list[dict]:
"""按产品筛选知识库记录(用于离线模式常见问题)
匹配规则(OR 逻辑,任一命中即返回):
1. 记录的 apk_product 字段包含产品名
2. 记录的 title 字段包含产品名
3. 记录的 phenomenon 字段包含产品名
4. 记录的 project 字段包含产品名
5. 记录的 keywords 与产品 tags 有交集
Args:
apk_product: APK 产品名称(如"门口屏5.0"、"桌牌")
tags: 产品标签列表(如["门口屏", "MQTT"])
limit: 返回记录数量上限
Returns:
匹配的记录列表(完整记录数据)
"""
if tags is None:
tags = []
results = []
apk_lower = apk_product.lower() if apk_product else ''
tags_lower = [t.lower() for t in tags] if tags else []
for record in self.records:
matched = False
# 规则1:apk_product 字段匹配
if apk_lower:
record_apk = record.get('apk_product', '').lower()
if apk_lower in record_apk or record_apk in apk_lower:
matched = True
# 规则2:title 包含产品名
if not matched and apk_lower:
if apk_lower in record.get('title', '').lower():
matched = True
# 规则3:phenomenon 包含产品名
if not matched and apk_lower:
if apk_lower in record.get('phenomenon', '').lower():
matched = True
# 规则4:project 字段匹配
if not matched and apk_lower:
if apk_lower in record.get('project', '').lower():
matched = True
# 规则5:keywords 与 tags 有交集
if not matched and tags_lower:
record_keywords = [kw.lower() for kw in record.get('keywords', [])]
if any(t in record_keywords for t in tags_lower):
matched = True
if matched:
results.append(record)
if len(results) >= limit:
break
return results
def get_search_mode(self): def get_search_mode(self):
"""获取当前搜索模式(供 /api/health 使用)""" """获取当前搜索模式(供 /api/health 使用)"""
return { return {
......
此差异已折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论