# PRD 计划执行文档 — 问题排查模块双模式界面升级

> 版本：1.0 ｜ 日期：2026-07-29 ｜ 分支：troubleshoot-ai-assistant

---

## 执行步骤总览

| 步骤 | 内容 | 优先级 | 预估改动 |
|------|------|--------|----------|
| 1 | 后端：产品配置文件 + 产品 API | P0 | 新建 2 文件，修改 1 文件 |
| 2 | 后端：文档下载 API | P0 | 修改 1 文件 |
| 3 | 后端：产品常见问题 API | P0 | 修改 1 文件 |
| 4 | 前端：类型定义 + API 封装 | P0 | 修改 2 文件 |
| 5 | 前端：离线模式布局组件 | P0 | 新建 1 文件 |
| 6 | 前端：产品树组件 | P0 | 新建 1 文件 |
| 7 | 前端：产品资料面板组件 | P0 | 新建 1 文件 |
| 8 | 前端：常见问题面板组件 | P0 | 新建 1 文件 |
| 9 | 前端：Index.vue 模式判断入口 | P0 | 修改 1 文件 |
| 10 | 单元测试 + 集成测试 | P1 | 新建/修改测试文件 |

---

## 步骤 1：后端产品配置 + 产品 API

### 1.1 新建产品配置文件

**文件**：`skill/code/web/products.json`

```json
{
  "products": [
    {
      "id": "door-screen-5.0",
      "name": "门口屏5.0",
      "system_type": "标准版预定2.0",
      "apk_product": "门口屏5.0",
      "documents": [
        {
          "id": "doc-001",
          "title": "门口屏5.0维护手册",
          "filename": "01-门口屏5.0维护手册.docx",
          "type": "docx",
          "size": "2.5MB"
        }
      ],
      "tags": ["门口屏", "会议显示"]
    },
    {
      "id": "desk-sign",
      "name": "桌牌系统",
      "system_type": "标准版预定2.0",
      "apk_product": "桌牌",
      "documents": [
        {
          "id": "doc-002",
          "title": "桌牌系统维护手册",
          "filename": "02-桌牌系统维护手册.docx",
          "type": "docx",
          "size": "1.8MB"
        }
      ],
      "tags": ["桌牌", "电子桌牌"]
    }
  ]
}
```

### 1.2 新建产品服务模块

**文件**：`skill/code/web/services/product_service.py`

```python
"""产品服务模块 — 离线模式产品资料与常见问题"""

import json
from pathlib import Path
from typing import Optional
from utils.paths import SCRIPT_DIR
from utils.logger import get_logger

logger = get_logger(__name__)

PRODUCTS_FILE = SCRIPT_DIR / "products.json"
DOCS_DIR = SCRIPT_DIR.parent.parent / "Docs" / "维护手册"


class ProductService:
    """产品服务：管理产品列表、资料、常见问题"""
    
    def __init__(self):
        self._products = None
        self._load_products()
    
    def _load_products(self):
        """加载产品配置"""
        try:
            if PRODUCTS_FILE.exists():
                with open(PRODUCTS_FILE, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    self._products = data.get('products', [])
            else:
                self._products = []
                logger.warning(f"产品配置文件不存在: {PRODUCTS_FILE}")
        except Exception as e:
            logger.error(f"加载产品配置失败: {e}")
            self._products = []
    
    def get_products(self) -> list[dict]:
        """获取所有产品列表"""
        # 为每个产品附加常见问题数量
        products = []
        for p in self._products:
            product = dict(p)
            # 查询该产品的知识库记录数量
            from container import get_search_engine
            engine = get_search_engine()
            if engine and product.get('apk_product'):
                records = engine.search_by_product(product['apk_product'], limit=1000)
                product['faq_count'] = len(records)
            else:
                product['faq_count'] = 0
            products.append(product)
        return products
    
    def get_product(self, product_id: str) -> Optional[dict]:
        """获取单个产品详情"""
        for p in self._products:
            if p['id'] == product_id:
                return dict(p)
        return None
    
    def get_documents(self, product_id: str) -> list[dict]:
        """获取产品的资料文档列表"""
        product = self.get_product(product_id)
        if product:
            return product.get('documents', [])
        return []
    
    def get_document_path(self, doc_id: str) -> Optional[Path]:
        """获取文档文件路径"""
        for p in self._products:
            for doc in p.get('documents', []):
                if doc['id'] == doc_id:
                    file_path = DOCS_DIR / doc['filename']
                    if file_path.exists():
                        return file_path
        return None
    
    def get_faqs(self, product_id: str, limit: int = 50) -> list[dict]:
        """获取产品的常见问题列表"""
        product = self.get_product(product_id)
        if not product:
            return []
        
        # 从知识库搜索该产品的记录
        apk_product = product.get('apk_product', '')
        if not apk_product:
            return []
        
        from container import get_search_engine
        engine = get_search_engine()
        if not engine:
            return []
        
        records = engine.search_by_product(apk_product, limit=limit)
        
        # 转换为 FAQ 格式
        faqs = []
        for r in records:
            faqs.append({
                'record_id': r.get('file', '').replace('.md', ''),
                'title': r.get('title', ''),
                'phenomenon': r.get('phenomenon', ''),
                'solution': r.get('solution', ''),
            })
        return faqs


# 单例（通过 container 管理）
_product_service = None

def get_product_service() -> ProductService:
    """获取产品服务单例"""
    global _product_service
    if _product_service is None:
        _product_service = ProductService()
    return _product_service
```

### 1.3 修改路由文件

**文件**：`skill/code/web/routes/troubleshoot.py`

在现有路由基础上新增产品相关 API：

```python
# === 新增导入 ===
from services.product_service import get_product_service
from flask import send_file

# === 新增路由 ===

@troubleshoot_bp.route('/products', methods=['GET'])
def get_products():
    """获取产品列表（离线模式）"""
    try:
        service = get_product_service()
        products = service.get_products()
        return jsonify({
            'success': True,
            'products': products,
            'total': len(products)
        })
    except Exception as e:
        logger.error(f"获取产品列表失败: {e}")
        return jsonify({'success': False, 'message': str(e)}), 500


@troubleshoot_bp.route('/products/<product_id>', methods=['GET'])
def get_product(product_id):
    """获取产品详情（离线模式）"""
    try:
        service = get_product_service()
        product = service.get_product(product_id)
        if product:
            return jsonify({'success': True, 'product': product})
        return jsonify({'success': False, 'message': '产品不存在'}), 404
    except Exception as e:
        logger.error(f"获取产品详情失败: {e}")
        return jsonify({'success': False, 'message': str(e)}), 500


@troubleshoot_bp.route('/products/<product_id>/documents', methods=['GET'])
def get_product_documents(product_id):
    """获取产品资料列表（离线模式）"""
    try:
        service = get_product_service()
        documents = service.get_documents(product_id)
        return jsonify({
            'success': True,
            'documents': documents,
            'product_id': product_id
        })
    except Exception as e:
        logger.error(f"获取产品资料失败: {e}")
        return jsonify({'success': False, 'message': str(e)}), 500


@troubleshoot_bp.route('/documents/<doc_id>/download', methods=['GET'])
def download_document(doc_id):
    """下载文档文件（离线模式）"""
    try:
        service = get_product_service()
        file_path = service.get_document_path(doc_id)
        if file_path:
            return send_file(
                file_path,
                as_attachment=True,
                download_name=file_path.name
            )
        return jsonify({'success': False, 'message': '文档不存在'}), 404
    except Exception as e:
        logger.error(f"下载文档失败: {e}")
        return jsonify({'success': False, 'message': str(e)}), 500


@troubleshoot_bp.route('/products/<product_id>/faqs', methods=['GET'])
def get_product_faqs(product_id):
    """获取产品常见问题列表（离线模式）"""
    try:
        limit = request.args.get('limit', 50, type=int)
        service = get_product_service()
        faqs = service.get_faqs(product_id, limit=limit)
        return jsonify({
            'success': True,
            'faqs': faqs,
            'product_id': product_id,
            'total': len(faqs)
        })
    except Exception as e:
        logger.error(f"获取常见问题失败: {e}")
        return jsonify({'success': False, 'message': str(e)}), 500
```

### 1.4 修改搜索引擎

**文件**：`skill/code/web/search_engine.py`

新增按产品筛选方法（保持公开接口不变，仅新增）：

```python
def search_by_product(self, apk_product: str, limit: int = 50) -> list[dict]:
    """按产品筛选知识库记录（用于离线模式常见问题）
    
    参数:
        apk_product: APK 产品名称（如"门口屏5.0"、"桌牌"）
        limit: 返回记录数量上限
    
    返回:
        匹配的记录列表（完整记录数据）
    """
    results = []
    for record in self.records:
        # 匹配 apk_product 字段或标题/内容中包含产品名
        record_product = record.get('apk_product', '')
        if apk_product.lower() in record_product.lower():
            results.append(record)
        elif apk_product.lower() in record.get('title', '').lower():
            results.append(record)
        elif apk_product.lower() in record.get('phenomenon', '').lower():
            results.append(record)
        
        if len(results) >= limit:
            break
    
    return results
```

---

## 步骤 2：前端类型定义 + API 封装

### 2.1 类型定义

**文件**：`frontend/src/types/troubleshoot.ts`

新增：

```typescript
/** 产品信息（离线模式） */
export interface Product {
  id: string
  name: string
  system_type?: string
  apk_product?: string
  documents?: Document[]
  faq_count?: number
  tags?: string[]
}

/** 文档信息 */
export interface Document {
  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: Document[]
  product_id: string
}

/** 常见问题列表响应 */
export interface FAQsResponse {
  success: boolean
  faqs: FAQ[]
  product_id: string
  total: number
}
```

### 2.2 API 封装

**文件**：`frontend/src/api/troubleshoot.ts`

新增：

```typescript
// === 产品相关 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
}
```

---

## 步骤 3：前端离线模式布局组件

### 3.1 离线模式主组件

**文件**：`frontend/src/views/troubleshoot/OfflineIndex.vue`

```vue
<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 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, nodeType: 'documents' | 'faqs') {
  selectedProductId.value = productId
  selectedNodeType.value = nodeType
}

onMounted(() => {
  loadProducts()
})
</script>

<template>
  <div class="page-offline-troubleshoot">
    <AppNavbar 
      show-module 
      module-label="运行维护平台（离线）" 
      home-path="/troubleshoot" 
    />
    
    <div class="main-container">
      <!-- 左侧面板：产品树 -->
      <aside class="left-panel">
        <div class="search-box">
          <el-input
            v-model="searchKeyword"
            placeholder="搜索产品..."
            clearable
            prefix-icon="Search"
          />
        </div>
        
        <ProductTree
          :products="filteredProducts"
          :loading="loading"
          @select="handleNodeSelect"
        />
      </aside>
      
      <!-- 右侧面板：内容区 -->
      <main class="right-panel">
        <div v-if="!selectedProductId" class="empty-state">
          <div class="empty-icon">📋</div>
          <p>请从左侧选择产品资料或常见问题</p>
        </div>
        
        <DocumentsPanel
          v-else-if="selectedNodeType === 'documents'"
          :product-id="selectedProductId"
        />
        
        <FAQsPanel
          v-else-if="selectedNodeType === 'faqs'"
          :product-id="selectedProductId"
        />
      </main>
    </div>
  </div>
</template>

<style scoped lang="scss">
.page-offline-troubleshoot {
  min-height: 100vh;
  background: #f5f7fa;
  padding-top: 56px;
}

.main-container {
  display: flex;
  height: calc(100vh - 56px);
}

.left-panel {
  width: 280px;
  background: white;
  border-right: 1px solid #e4e7ed;
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
}

.search-box {
  padding: 16px;
  border-bottom: 1px solid #e4e7ed;
}

.right-panel {
  flex: 1;
  overflow-y: auto;
  padding: 24px;
}

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: #909399;
  
  .empty-icon {
    font-size: 64px;
    margin-bottom: 16px;
  }
  
  p {
    font-size: 16px;
  }
}

@media screen and (max-width: 768px) {
  .main-container {
    flex-direction: column;
  }
  
  .left-panel {
    width: 100%;
    height: auto;
    max-height: 40vh;
  }
  
  .right-panel {
    padding: 16px;
  }
}
</style>
```

---

## 步骤 4：前端产品树组件

**文件**：`frontend/src/components/troubleshoot/ProductTree.vue`

```vue
<script setup lang="ts">
import { ref } from 'vue'
import type { Product } from '@/types/troubleshoot'

interface Props {
  products: Product[]
  loading?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  loading: false
})

const emit = defineEmits<{
  select: [productId: string, nodeType: 'documents' | 'faqs']
}>()

// 展开状态
const expandedProducts = ref<Set<string>>(new Set())

function toggleProduct(productId: string) {
  if (expandedProducts.value.has(productId)) {
    expandedProducts.value.delete(productId)
  } else {
    expandedProducts.value.add(productId)
  }
}

function selectNode(productId: string, nodeType: 'documents' | 'faqs') {
  emit('select', productId, 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 '👥'
  return '📦'
}
</script>

<template>
  <div class="product-tree" v-loading="loading">
    <div v-if="products.length === 0 && !loading" class="empty">
      暂无产品数据
    </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">
            {{ expandedProducts.has(product.id) ? '▼' : '▶' }}
          </span>
          <span class="product-icon">{{ getProductIcon(product) }}</span>
          <span class="product-name">{{ product.name }}</span>
          <span v-if="product.faq_count" class="faq-badge">
            {{ product.faq_count }}
          </span>
        </div>
        
        <!-- 子节点 -->
        <div 
          v-show="expandedProducts.has(product.id)"
          class="product-children"
        >
          <div 
            class="child-node"
            @click="selectNode(product.id, 'documents')"
          >
            <span class="child-icon">📁</span>
            <span>产品资料</span>
            <span v-if="product.documents?.length" class="count-badge">
              {{ product.documents.length }}
            </span>
          </div>
          
          <div 
            class="child-node"
            @click="selectNode(product.id, 'faqs')"
          >
            <span class="child-icon">❓</span>
            <span>常见问题</span>
            <span v-if="product.faq_count" class="count-badge">
              {{ product.faq_count }}
            </span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
.product-tree {
  flex: 1;
  overflow-y: auto;
  padding: 8px 0;
}

.empty {
  padding: 24px;
  text-align: center;
  color: #909399;
}

.tree-list {
  padding: 0 8px;
}

.product-node {
  margin-bottom: 4px;
}

.product-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  cursor: pointer;
  border-radius: 6px;
  transition: background 0.2s;
  
  &:hover {
    background: #f5f7fa;
  }
}

.expand-icon {
  font-size: 10px;
  color: #909399;
  width: 12px;
}

.product-icon {
  font-size: 18px;
}

.product-name {
  flex: 1;
  font-size: 14px;
  font-weight: 500;
  color: #303133;
}

.faq-badge {
  background: #409eff;
  color: white;
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 10px;
}

.product-children {
  margin-left: 20px;
  padding: 4px 0;
}

.child-node {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  cursor: pointer;
  border-radius: 4px;
  font-size: 13px;
  color: #606266;
  transition: background 0.2s;
  
  &:hover {
    background: #ecf5ff;
    color: #409eff;
  }
}

.child-icon {
  font-size: 16px;
}

.count-badge {
  background: #e4e7ed;
  color: #909399;
  font-size: 11px;
  padding: 2px 6px;
  border-radius: 8px;
}
</style>
```

---

## 步骤 5：前端产品资料面板组件

**文件**：`frontend/src/components/troubleshoot/DocumentsPanel.vue`

```vue
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { getProductDocuments, downloadDocument, getProduct } from '@/api/troubleshoot'
import type { Document, Product } from '@/types/troubleshoot'
import { ElMessage } from 'element-plus'

interface Props {
  productId: string
}

const props = defineProps<Props>()

const product = ref<Product | null>(null)
const documents = ref<Document[]>([])
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 (e) {
    ElMessage.error('加载资料失败')
  } finally {
    loading.value = false
  }
}

function handleDownload(doc: Document) {
  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 '📄'
  }
}

watch(() => props.productId, loadData, { immediate: true })
</script>

<template>
  <div class="documents-panel" v-loading="loading">
    <!-- 标题 -->
    <div class="panel-header">
      <h2>📁 {{ product?.name || '产品资料' }}</h2>
      <span class="doc-count">共 {{ documents.length }} 份文档</span>
    </div>
    
    <!-- 空状态 -->
    <div v-if="!loading && documents.length === 0" class="empty-state">
      <div class="empty-icon">📂</div>
      <p>该产品暂无资料文档</p>
    </div>
    
    <!-- 文档列表 -->
    <div v-else class="doc-list">
      <div 
        v-for="doc in documents"
        :key="doc.id"
        class="doc-item"
        @click="handleDownload(doc)"
      >
        <span class="doc-icon">{{ getFileIcon(doc.type) }}</span>
        <div class="doc-info">
          <div class="doc-title">{{ doc.title }}</div>
          <div class="doc-meta">
            <span class="doc-type">{{ doc.type.toUpperCase() }}</span>
            <span v-if="doc.size" class="doc-size">{{ doc.size }}</span>
          </div>
        </div>
        <el-button type="primary" link>
          <el-icon><Download /></el-icon>
          下载
        </el-button>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
.documents-panel {
  background: white;
  border-radius: 12px;
  padding: 24px;
  min-height: 400px;
}

.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 24px;
  padding-bottom: 16px;
  border-bottom: 1px solid #e4e7ed;
  
  h2 {
    font-size: 20px;
    font-weight: 600;
    color: #303133;
    margin: 0;
  }
  
  .doc-count {
    font-size: 14px;
    color: #909399;
  }
}

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 300px;
  color: #909399;
  
  .empty-icon {
    font-size: 48px;
    margin-bottom: 12px;
  }
}

.doc-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.doc-item {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 16px;
  background: #f5f7fa;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s;
  
  &:hover {
    background: #ecf5ff;
    transform: translateX(4px);
  }
}

.doc-icon {
  font-size: 32px;
}

.doc-info {
  flex: 1;
}

.doc-title {
  font-size: 15px;
  font-weight: 500;
  color: #303133;
  margin-bottom: 4px;
}

.doc-meta {
  display: flex;
  gap: 12px;
  font-size: 12px;
  color: #909399;
}

.doc-type {
  background: #e4e7ed;
  padding: 2px 8px;
  border-radius: 4px;
  font-weight: 500;
}
</style>
```

---

## 步骤 6：前端常见问题面板组件

**文件**：`frontend/src/components/troubleshoot/FAQsPanel.vue`

```vue
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getProductFAQs, getProduct } from '@/api/troubleshoot'
import http from '@/utils/http'
import type { FAQ, Product } from '@/types/troubleshoot'
import { renderMarkdown } from '@/utils/markdown'
import { ElMessage } from 'element-plus'

interface Props {
  productId: 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 (e) {
    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 {
    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
    } else {
      faqDetail.value = 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">
      <h2>❓ {{ product?.name || '常见问题' }}</h2>
      <span class="faq-count">共 {{ faqs.length }} 个问题</span>
    </div>
    
    <!-- 空状态 -->
    <div v-if="!loading && faqs.length === 0" class="empty-state">
      <div class="empty-icon">🔍</div>
      <p>该产品暂无常见问题记录</p>
    </div>
    
    <!-- 问题列表 -->
    <div v-else class="faq-list">
      <div 
        v-for="faq in faqs"
        :key="faq.record_id"
        class="faq-item"
        :class="{ expanded: expandedFAQ === faq.record_id }"
      >
        <!-- 问题标题 -->
        <div class="faq-header" @click="toggleFAQ(faq)">
          <span class="expand-icon">
            {{ expandedFAQ === faq.record_id ? '▼' : '▶' }}
          </span>
          <span class="faq-title">{{ faq.title }}</span>
        </div>
        
        <!-- 问题摘要 -->
        <div v-if="expandedFAQ !== faq.record_id" class="faq-summary">
          {{ faq.phenomenon?.slice(0, 100) }}...
        </div>
        
        <!-- 问题详情 -->
        <div v-else class="faq-detail" v-loading="faqDetailLoading">
          <div class="detail-section">
            <h4>问题现象</h4>
            <p>{{ faq.phenomenon }}</p>
          </div>
          
          <div class="detail-section">
            <h4>排查指引</h4>
            <div 
              v-if="faqDetail"
              class="markdown-content"
              v-html="renderMarkdown(faqDetail)"
            />
            <p v-else>暂无详细内容</p>
          </div>
          
          <div class="detail-actions">
            <el-button type="primary" size="small" @click="downloadFAQRecord(faq)">
              📥 下载文档
            </el-button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">
.faqs-panel {
  background: white;
  border-radius: 12px;
  padding: 24px;
  min-height: 400px;
}

.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 24px;
  padding-bottom: 16px;
  border-bottom: 1px solid #e4e7ed;
  
  h2 {
    font-size: 20px;
    font-weight: 600;
    color: #303133;
    margin: 0;
  }
  
  .faq-count {
    font-size: 14px;
    color: #909399;
  }
}

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 300px;
  color: #909399;
  
  .empty-icon {
    font-size: 48px;
    margin-bottom: 12px;
  }
}

.faq-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.faq-item {
  background: #f5f7fa;
  border-radius: 8px;
  overflow: hidden;
  transition: all 0.2s;
  
  &.expanded {
    background: white;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }
}

.faq-header {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px;
  cursor: pointer;
  
  &:hover {
    background: #ecf5ff;
  }
  
  .expand-icon {
    font-size: 10px;
    color: #909399;
  }
  
  .faq-title {
    flex: 1;
    font-size: 15px;
    font-weight: 500;
    color: #303133;
  }
}

.faq-summary {
  padding: 0 16px 16px 40px;
  font-size: 13px;
  color: #909399;
  line-height: 1.6;
}

.faq-detail {
  padding: 0 16px 16px 16px;
  border-top: 1px solid #e4e7ed;
  margin-top: 0;
}

.detail-section {
  margin-top: 16px;
  
  h4 {
    font-size: 14px;
    font-weight: 600;
    color: #409eff;
    margin-bottom: 8px;
  }
  
  p {
    font-size: 14px;
    color: #606266;
    line-height: 1.8;
  }
}

.markdown-content {
  background: #fafafa;
  padding: 16px;
  border-radius: 8px;
  max-height: 400px;
  overflow-y: auto;
  line-height: 1.8;
}

.detail-actions {
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px solid #e4e7ed;
}
</style>
```

---

## 步骤 7：前端 Index.vue 模式判断入口

**文件**：`frontend/src/views/troubleshoot/Index.vue`

修改入口组件，根据 `offline_mode` 判断显示哪个界面：

```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import OnlineIndex from '@/views/troubleshoot/OnlineIndex.vue'
import OfflineIndex from '@/views/troubleshoot/OfflineIndex.vue'
import { getHealth } from '@/api/troubleshoot'
import type { HealthResponse } from '@/types/troubleshoot'

const offlineMode = ref(false)
const loading = ref(true)

async function checkMode() {
  try {
    const res = await getHealth()
    offlineMode.value = res.offline_mode === true
  } catch {
    // 默认在线模式
    offlineMode.value = false
  } finally {
    loading.value = false
  }
}

onMounted(() => {
  checkMode()
})
</script>

<template>
  <div class="troubleshoot-entry">
    <div v-if="loading" class="loading-mask">
      <div class="loading-spinner" />
      <p>正在加载...</p>
    </div>
    
    <!-- 在线模式 -->
    <OnlineIndex v-else-if="!offlineMode" />
    
    <!-- 离线模式 -->
    <OfflineIndex v-else />
  </div>
</template>

<style scoped lang="scss">
.troubleshoot-entry {
  min-height: 100vh;
}

.loading-mask {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
}

.loading-spinner {
  width: 40px;
  height: 40px;
  border: 3px solid rgba(255, 255, 255, 0.3);
  border-top-color: white;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}
</style>
```

**注意**：需要将现有 `Index.vue` 内容移动到 `OnlineIndex.vue`。

---

## 变更文件清单

| 文件 | 操作 | 说明 |
|------|------|------|
| `skill/code/web/products.json` | **新建** | 产品配置文件 |
| `skill/code/web/services/product_service.py` | **新建** | 产品服务模块 |
| `skill/code/web/search_engine.py` | 修改 | 新增 `search_by_product` 方法 |
| `skill/code/web/routes/troubleshoot.py` | 修改 | 新增 5 个产品相关 API |
| `frontend/src/types/troubleshoot.ts` | 修改 | 新增产品/文档/FAQ 类型 |
| `frontend/src/api/troubleshoot.ts` | 修改 | 新增产品相关 API 函数 |
| `frontend/src/views/troubleshoot/Index.vue` | 修改 | 改为模式判断入口 |
| `frontend/src/views/troubleshoot/OnlineIndex.vue` | **新建** | 在线模式界面（原 Index.vue 内容） |
| `frontend/src/views/troubleshoot/OfflineIndex.vue` | **新建** | 离线模式主界面 |
| `frontend/src/components/troubleshoot/ProductTree.vue` | **新建** | 产品树组件 |
| `frontend/src/components/troubleshoot/DocumentsPanel.vue` | **新建** | 产品资料面板组件 |
| `frontend/src/components/troubleshoot/FAQsPanel.vue` | **新建** | 常见问题面板组件 |
| `skill/code/tests/test_product_service.py` | **新建** | 产品服务单元测试 |

---

## 验证清单

### 后端验证

- [ ] `GET /api/troubleshoot/products` 返回产品列表
- [ ] `GET /api/troubleshoot/products/{id}` 返回产品详情
- [ ] `GET /api/troubleshoot/products/{id}/documents` 返回文档列表
- [ ] `GET /api/troubleshoot/documents/{id}/download` 文件下载正常
- [ ] `GET /api/troubleshoot/products/{id}/faqs` 返回常见问题
- [ ] `search_engine.search_by_product()` 正确筛选记录

### 前端验证

- [ ] `OFFLINE_MODE=false` 显示在线模式界面
- [ ] `OFFLINE_MODE=true` 显示离线模式界面
- [ ] 离线模式左侧产品树正常展示
- [ ] 点击"产品资料"右侧展示文档列表
- [ ] 点击文档名称可下载文件
- [ ] 点击"常见问题"右侧展示问题列表
- [ ] 点击问题展开排查指引
- [ ] 搜索框可过滤产品列表

### 单元测试

- [ ] `pytest skill/code/tests/test_product_service.py` 全绿
- [ ] `pytest skill/code/tests/test_search_engine.py` 全绿（回归测试）

---

## 部署步骤

1. 后端：将 `Docs/维护手册/` 目录下的文档文件准备好
2. 后端：配置 `products.json` 定义产品与文档映射
3. 前端：`npm run build`
4. 部署：更新 Docker 镜像
5. 验证：访问 `/api/health` 确认 `offline_mode` 正确
6. 验证：访问问题排查模块确认界面正确

---

## 用户提供项清单

在实施前，用户需提供：

1. **产品清单**：产品名称、系统类型、APK 产品标识
2. **产品资料文件**：PDF/DOCX/MD 等文档文件
3. **产品与知识库映射**：哪些知识库记录属于哪个产品（可按 `apk_product` 字段匹配）
4. **排查指引内容**：常见问题的详细排查步骤（可逐步补充，初期可复用知识库记录）
