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

feat(offline-mode): 管理员上传/删除文档功能 + 常见问题置空

- product_service.py 新增 add_document/delete_document 方法
- routes/troubleshoot.py 新增 POST /documents/upload + DELETE /{id}/delete
- DocumentsPanel.vue 新增上传/删除按钮(管理员专属)
- FAQsPanel.vue 常见问题置空为「整理中」占位
- nginx.conf 新增 client_max_body_size 100m
- HANDOFF.md 更新会话交接文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 09ae4ab5
此差异已折叠。
......@@ -138,4 +138,21 @@ export async function getProductFAQs(productId: string, limit?: number): Promise
const params = limit ? { limit } : {}
const res = await http.get(`/api/troubleshoot/products/${productId}/faqs`, { params })
return res.data
}
/** 上传文档(管理员) */
export async function uploadDocument(productId: string, file: File): Promise<{ success: boolean; doc_id?: string; message?: string }> {
const formData = new FormData()
formData.append('product_id', productId)
formData.append('file', file)
const res = await http.post('/api/troubleshoot/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return res.data
}
/** 删除文档(管理员) */
export async function deleteDocument(docId: string): Promise<{ success: boolean; message?: string }> {
const res = await http.delete(`/api/troubleshoot/documents/${docId}/delete`)
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 { getProductDocuments, downloadDocument, getProduct, uploadDocument, deleteDocument } from '@/api/troubleshoot'
import type { ProductDocument, Product } from '@/types/troubleshoot'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
interface Props {
productId: string
......@@ -11,9 +12,12 @@ interface Props {
const props = defineProps<Props>()
const userStore = useUserStore()
const product = ref<Product | null>(null)
const documents = ref<ProductDocument[]>([])
const loading = ref(true)
const uploading = ref(false)
const deletingId = ref<string | null>(null)
async function loadData() {
loading.value = true
......@@ -39,8 +43,64 @@ async function loadData() {
function handleDownload(doc: ProductDocument) {
const url = downloadDocument(doc.id)
window.open(url, '_blank')
ElMessage.success(`正在下载:${doc.title}`)
const link = document.createElement('a')
link.href = url
link.download = doc.title + '.' + doc.type
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('正在下载:' + doc.title)
}
async function handleUpload() {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.docx,.pdf,.md,.doc,.xlsx,.xls'
input.onchange = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
uploading.value = true
try {
const res = await uploadDocument(props.productId, file)
if (res.success) {
ElMessage.success('上传成功!')
await loadData()
} else {
ElMessage.error(res.message || '上传失败')
}
} catch {
ElMessage.error('上传异常')
} finally {
uploading.value = false
}
}
input.click()
}
async function handleDelete(doc: ProductDocument) {
try {
await ElMessageBox.confirm(
'确定要删除「' + doc.title + '」吗?此操作不可撤销。',
'确认删除',
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
)
deletingId.value = doc.id
const res = await deleteDocument(doc.id)
if (res.success) {
ElMessage.success('已删除')
await loadData()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch {
// 取消操作
} finally {
deletingId.value = null
}
}
function getFileIcon(type: string): string {
......@@ -71,13 +131,24 @@ watch(() => props.productId, loadData, { immediate: true })
</script>
<template>
<div class="documents-panel" v-loading="loading">
<div class="documents-panel" v-loading="loading || uploading">
<!-- 标题 -->
<div class="panel-header">
<div class="header-left">
<h2>📁 {{ productName }} — 产品资料</h2>
</div>
<span class="doc-count">{{ documents.length }} 份文档</span>
<div class="header-right">
<span class="doc-count">{{ documents.length }} 份文档</span>
<el-button
v-if="userStore.isAdmin"
type="primary"
size="small"
:loading="uploading"
@click="handleUpload"
>
➕ 上传文档
</el-button>
</div>
</div>
<!-- 空状态 -->
......@@ -111,10 +182,21 @@ watch(() => props.productId, loadData, { immediate: true })
<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 class="doc-actions">
<el-button type="primary" size="small" @click="handleDownload(doc)">
<span class="btn-icon"></span>
下载
</el-button>
<el-button
v-if="userStore.isAdmin"
type="danger"
size="small"
:loading="deletingId === doc.id"
@click="handleDelete(doc)"
>
🗑
</el-button>
</div>
</div>
</div>
</div>
......@@ -144,6 +226,12 @@ watch(() => props.productId, loadData, { immediate: true })
margin: 0;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.doc-count {
font-size: 13px;
color: #86909c;
......@@ -255,6 +343,12 @@ watch(() => props.productId, loadData, { immediate: true })
color: #86909c;
}
.doc-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-icon {
margin-right: 4px;
}
......
......@@ -2,7 +2,6 @@
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 {
......@@ -14,14 +13,17 @@ const props = defineProps<Props>()
const product = ref<Product | null>(null)
const faqs = ref<FAQ[]>([])
const loading = ref(true)
// 当前展开的问题
const loading = ref(false) // 改为 false,不加载
const expandedFAQ = ref<string | null>(null)
const faqDetail = ref<string>('')
const faqDetailLoading = ref(false)
// 暂时禁用加载
async function loadData() {
// 常见问题暂时置空,后续开放
faqs.value = []
return
loading.value = true
expandedFAQ.value = null
faqDetail.value = ''
......@@ -89,10 +91,10 @@ watch(() => props.productId, loadData, { immediate: true })
</div>
<!-- 空状态 -->
<div v-if="!loading && faqs.length === 0" class="empty-state">
<div class="empty-icon">🔍</div>
<h3>暂无常见问题</h3>
<p>该产品暂未录入常见问题与排查指引</p>
<div v-if="faqs.length === 0" class="empty-state">
<div class="empty-icon">📝</div>
<h3>常见问题整理中</h3>
<p>排查指引正在整理,敬请期待</p>
</div>
<!-- 问题列表 -->
......@@ -141,7 +143,7 @@ watch(() => props.productId, loadData, { immediate: true })
<div
v-if="faqDetail"
class="markdown-content"
v-html="renderMarkdown(faqDetail)"
v-html="faqDetail"
/>
<p v-else class="no-content">暂无详细内容</p>
</div>
......
......@@ -41,6 +41,9 @@ http {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1024;
# 大文件上传支持
client_max_body_size 100m;
server {
listen 80;
server_name _;
......
......@@ -638,6 +638,59 @@ def download_document(doc_id):
return jsonify({'success': False, 'message': str(e)}), 500
@bp.route('/api/troubleshoot/documents/upload', methods=['POST'])
def upload_document():
"""上传文档(管理员,离线模式)
请求:multipart/form-data
- product_id: 产品 ID
- file: 文件内容
返回:{ success, doc_id, message }
"""
# 权限检查:仅管理员
user = session.get('user') or {}
if user.get('role') != 'admin':
return jsonify({'success': False, 'message': '仅管理员可上传文档'}), 403
product_id = request.form.get('product_id', '')
file = request.files.get('file')
if not product_id:
return jsonify({'success': False, 'message': '请指定产品 ID'}), 400
if not file or file.filename == '':
return jsonify({'success': False, 'message': '请选择要上传的文件'}), 400
try:
service = get_product_service()
result = service.add_document(product_id, file.filename, file.read())
if result['success']:
return jsonify(result)
return jsonify(result), 400
except Exception as e:
logger.exception('上传文档失败')
return jsonify({'success': False, 'message': str(e)}), 500
@bp.route('/api/troubleshoot/documents/<doc_id>/delete', methods=['DELETE'])
def delete_document(doc_id):
"""删除文档(管理员,离线模式)"""
# 权限检查:仅管理员
user = session.get('user') or {}
if user.get('role') != 'admin':
return jsonify({'success': False, 'message': '仅管理员可删除文档'}), 403
try:
service = get_product_service()
result = service.delete_document(doc_id)
if result['success']:
return jsonify(result)
return jsonify(result), 400
except Exception as e:
logger.exception('删除文档失败')
return jsonify({'success': False, 'message': str(e)}), 500
@bp.route('/api/troubleshoot/products/<product_id>/faqs', methods=['GET'])
def get_product_faqs(product_id):
"""获取产品常见问题列表(离线模式)"""
......
......@@ -14,6 +14,7 @@ product_service.py — 产品服务模块(离线模式)
"""
import json
import shutil
from pathlib import Path
from typing import Optional
......@@ -156,6 +157,128 @@ class ProductService:
logger.error(f"[ProductService] 计算常见问题数量失败: {e}")
return 0
# ============================================================
# 管理员上传/删除文档
# ============================================================
def add_document(self, product_id: str, filename: str, file_data: bytes) -> dict:
"""上传文档到产品资料目录
Args:
product_id: 产品 ID
filename: 文件名(用户上传的原始文件名)
file_data: 文件二进制内容
Returns:
{'success': bool, 'doc_id': str, 'message': str}
"""
product = self.get_product(product_id)
if not product:
return {'success': False, 'message': '产品不存在'}
product_name = product.get('name', product_id)
product_dir = DOCS_DIR / product_name
# 去重:如果已存在同名文件,删除
existing_path = product_dir / filename
if existing_path.exists():
# 保留旧文件但更新
pass
# 确保目录存在
product_dir.mkdir(parents=True, exist_ok=True)
# 写入文件
try:
file_path = product_dir / filename
with open(file_path, 'wb') as f:
f.write(file_data)
# 更新 products.json 配置
doc_id = f'doc-{product_id}-{filename.replace(" ", "-").replace(".", "-")}'
relative_path = f'{product_name}/{filename}'
# 检查是否已存在于配置中
existing_docs = product.get('documents', [])
found = False
for doc in existing_docs:
if doc.get('filename') == relative_path:
found = True
break
if not found:
from utils.paths import SCRIPT_DIR
products_file = SCRIPT_DIR / 'products.json'
file_ext = filename.rsplit('.', 1)[-1] if '.' in filename else 'unknown'
existing_docs.append({
'id': doc_id,
'title': filename.rsplit('.', 1)[0] if '.' in filename else filename,
'filename': relative_path,
'type': file_ext.lower(),
})
# 写回配置
with open(products_file, 'r', encoding='utf-8') as f:
config = json.load(f)
for p in config.get('products', []):
if p['id'] == product_id:
p['documents'] = existing_docs
with open(products_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
# 重新加载配置
self._load_products()
logger.info(f"[ProductService] 文档已上传: {relative_path} ({len(file_data)} bytes)")
return {'success': True, 'doc_id': doc_id, 'message': f'文档 {filename} 上传成功', 'size': len(file_data)}
except Exception as e:
logger.error(f"[ProductService] 上传文档失败: {e}")
return {'success': False, 'message': str(e)}
def delete_document(self, doc_id: str) -> dict:
"""删除文档资料
Args:
doc_id: 文档 ID
Returns:
{'success': bool, 'message': str}
"""
try:
# 查找文档信息
for p in self._products:
for doc in p.get('documents', []):
if doc['id'] == doc_id:
filename = doc.get('filename', '')
product_id = p['id']
# 删除文件
file_path = DOCS_DIR / filename
if file_path.exists():
file_path.unlink()
# 从配置中移除
from utils.paths import SCRIPT_DIR
products_file = SCRIPT_DIR / 'products.json'
with open(products_file, 'r', encoding='utf-8') as f:
config = json.load(f)
for cp in config.get('products', []):
if cp['id'] == product_id:
cp['documents'] = [d for d in cp.get('documents', []) if d['id'] != doc_id]
with open(products_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
# 重新加载
self._load_products()
logger.info(f"[ProductService] 文档已删除: {filename}")
return {'success': True, 'message': f'文档已删除'}
return {'success': False, 'message': '文档不存在'}
except Exception as e:
logger.error(f"[ProductService] 删除文档失败: {e}")
return {'success': False, 'message': str(e)}
# 单例
_product_service = None
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论