This commit is contained in:
amb
2026-09-01 21:43:27 +08:00
parent e47235dfff
commit 2d42b0e73d
7 changed files with 2035 additions and 1171 deletions
+529 -210
View File
@@ -1,5 +1,11 @@
"""公共 AI 页面路由(/k/**)。 """公共 AI 页面路由(/k/**)。
核心设计:
- 主页 /k/{token} = 目录索引(TOC),显示所有目录及其文档数量
- 目录页 /k/{token}/category/{path} = 该目录下的文档列表
- 文档页 /k/{token}/doc/{doc_token} = 单个文档内容
- AI 先看目录索引 → 根据用户描述选择目录 → 进入该目录看文档
规则: 规则:
- 零 JS、零 Cookie、零登录、SSR 输出、标准 HTML - 零 JS、零 Cookie、零登录、SSR 输出、标准 HTML
- <meta name="robots" content="noindex,nofollow,noarchive"> - <meta name="robots" content="noindex,nofollow,noarchive">
@@ -9,6 +15,7 @@
""" """
import json import json
import re
from fastapi import APIRouter, Depends, Query, Request, Response from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse from fastapi.responses import HTMLResponse, PlainTextResponse
@@ -27,7 +34,7 @@ router = APIRouter(prefix="/k", tags=["public"])
def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None: def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: str | None = None, req_type: str | None = None) -> None:
"""记录访问日志(best-effort,不阻塞响应)。""" """记录访问日志(best-effort)。"""
try: try:
ua = request.headers.get("user-agent", "") ua = request.headers.get("user-agent", "")
AccessLogService(db).record( AccessLogService(db).record(
@@ -38,11 +45,10 @@ def _log_access(db: Session, kb_id: str, path: str, request: Request, doc_id: st
request_type=req_type, request_type=req_type,
) )
except Exception: except Exception:
pass # 日志失败不影响响应 pass
def _rate_limit(request: Request, token: str) -> None: def _rate_limit(request: Request, token: str) -> None:
"""限流检查。"""
ip = request.client.host if request.client else "unknown" ip = request.client.host if request.client else "unknown"
if not check_rate_limit(token_key=token[:16], ip_key=ip): if not check_rate_limit(token_key=token[:16], ip_key=ip):
raise RateLimitedError() raise RateLimitedError()
@@ -56,7 +62,87 @@ def _referrer_meta() -> str:
return '<meta name="referrer" content="no-referrer">' return '<meta name="referrer" content="no-referrer">'
# --- 知识库入口(后缀路由必须先于无后缀路由注册)--- def _get_category_by_path(db: Session, kb_id: str, path: str) -> DocumentCategory | None:
"""根据路径查找分类。"""
from sqlalchemy import select
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.path == path,
)
return db.scalars(stmt).first()
# ============================================================
# 目录索引(主页)- AI 从这里了解知识库结构
# ============================================================
@router.get("/{token}.json")
def kb_index_json(
token: str,
request: Request,
db: Session = Depends(get_db),
) -> Response:
"""知识库完整内容(JSON)- 按目录分组,包含所有文档内容。
AI 访问此接口获取知识库全部内容,按目录结构组织。
"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
_log_access(db, kb.id, f"/k/{token}.json", request, req_type="json")
category_tree = svc.get_category_tree(kb)
# 获取所有文档(按目录分组)
docs, _ = svc.list_documents(kb)
# 按 category_id 分组
doc_map: dict[str, list] = {}
for doc in docs:
cat_id = doc.category_id or "__uncategorized__"
if cat_id not in doc_map:
doc_map[cat_id] = []
doc_map[cat_id].append({
"title": doc.title or doc.original_filename,
"file_type": doc.file_ext,
"description": doc.description,
"summary": doc.content_summary,
"keywords": doc.keywords.split(",") if doc.keywords else [],
"updated_at": doc.updated_at,
})
# 递归构建目录 + 文档
def build_tree_with_docs(nodes: list) -> list:
result = []
for node in nodes:
cat_docs = doc_map.get(node["id"], [])
node_data = {
"name": node["name"],
"path": node["path"],
"is_folder": node["is_folder"],
"documents": cat_docs,
"children": build_tree_with_docs(node.get("children", [])),
}
result.append(node_data)
return result
tree_with_docs = build_tree_with_docs(category_tree)
# 未分类文档
uncategorized = doc_map.get("__uncategorized__", [])
data = {
"name": kb.name,
"description": kb.description,
"categories": tree_with_docs,
"uncategorized_documents": uncategorized,
}
return Response(
content=json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json",
)
@router.get("/{token}.md") @router.get("/{token}.md")
@@ -65,33 +151,39 @@ def kb_index_markdown(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> PlainTextResponse: ) -> PlainTextResponse:
"""知识库首页Markdown)。""" """知识库目录索引Markdown)。"""
_rate_limit(request, token) _rate_limit(request, token)
svc = KbPublicService(db) svc = KbPublicService(db)
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
_log_access(db, kb.id, f"/k/{token}.md", request, req_type="md") _log_access(db, kb.id, f"/k/{token}.md", request, req_type="md")
docs, _ = svc.list_documents(kb)
category_tree = svc.get_category_tree(kb)
lines = [f"# {kb.name}", ""] lines = [f"# {kb.name}", ""]
if kb.description: if kb.description:
lines.append(kb.description) lines.append(kb.description)
lines.append("") lines.append("")
lines.append("## 文档列表") lines.append("## 目录结构")
lines.append("")
lines.append("请通过以下目录链接访问对应内容:")
lines.append("") lines.append("")
for doc in docs: def render_tree_md(nodes: list, level: int = 0):
title = doc.title or doc.original_filename for node in nodes:
lines.append(f"### {title}") prefix = " " * level
lines.append(f"- 类型:{doc.file_ext}") icon = "📁" if node["is_folder"] else "📄"
if doc.description: doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else ""
lines.append(f"- 描述:{doc.description}") url = f"/k/{token}/category{node['path']}"
if doc.keywords: lines.append(f"{prefix}- {icon} [{node['name']}]({url}){doc_count}")
lines.append(f"- 关键词:{doc.keywords}") if node.get("children"):
if doc.content_summary: render_tree_md(node["children"], level + 1)
lines.append(f"- 摘要:{doc.content_summary}")
lines.append(f"- 更新时间:{doc.updated_at}") render_tree_md(category_tree)
lines.append("")
lines.append("")
lines.append("---")
lines.append(f"访问各目录链接查看具体文档。如需搜索:/k/{token}/search?q=关键词")
return PlainTextResponse(content="\n".join(lines), media_type="text/markdown") return PlainTextResponse(content="\n".join(lines), media_type="text/markdown")
@@ -102,48 +194,272 @@ def kb_index_text(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> PlainTextResponse: ) -> PlainTextResponse:
"""知识库首页(纯文本)。""" """知识库目录索引(纯文本)。"""
_rate_limit(request, token) _rate_limit(request, token)
svc = KbPublicService(db) svc = KbPublicService(db)
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
_log_access(db, kb.id, f"/k/{token}.txt", request, req_type="txt") _log_access(db, kb.id, f"/k/{token}.txt", request, req_type="txt")
docs, _ = svc.list_documents(kb)
category_tree = svc.get_category_tree(kb)
lines = [kb.name, "=" * len(kb.name), ""] lines = [kb.name, "=" * len(kb.name), ""]
if kb.description: if kb.description:
lines.append(kb.description) lines.append(kb.description)
lines.append("") lines.append("")
lines.append("文档列表") lines.append("目录结构")
lines.append("") lines.append("")
for i, doc in enumerate(docs, 1): def render_tree_txt(nodes: list, level: int = 0):
title = doc.title or doc.original_filename for node in nodes:
lines.append(f"{i}. {title}") prefix = " " * level
if doc.description: icon = "[文件夹]" if node["is_folder"] else "[文档]"
lines.append(f" 描述:{doc.description}") doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else ""
if doc.keywords: url = f"/k/{token}/category{node['path']}"
lines.append(f" 关键词:{doc.keywords}") lines.append(f"{prefix}{icon} {node['name']}{doc_count}")
lines.append("") lines.append(f"{prefix}{url}")
if node.get("children"):
render_tree_txt(node["children"], level + 1)
render_tree_txt(category_tree)
return PlainTextResponse(content="\n".join(lines), media_type="text/plain") return PlainTextResponse(content="\n".join(lines), media_type="text/plain")
@router.get("/{token}.json") @router.get("/{token}")
def kb_index_json( def kb_index_html(
token: str, token: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> Response: ) -> HTMLResponse:
"""知识库首页(JSON)。""" """知识库完整内容(HTML)- 按目录分组显示所有文档。
AI 直接读取此页面即可获取全部内容,无需点击。
人类用户可通过左侧目录快速跳转到对应章节。
"""
_rate_limit(request, token) _rate_limit(request, token)
svc = KbPublicService(db) svc = KbPublicService(db)
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
_log_access(db, kb.id, f"/k/{token}.json", request, req_type="json") _log_access(db, kb.id, f"/k/{token}", request, req_type="html")
category_tree = svc.get_category_tree(kb)
docs, _ = svc.list_documents(kb) docs, _ = svc.list_documents(kb)
# 按 category_id 分组
doc_map: dict[str, list] = {}
for doc in docs:
cat_id = doc.category_id or "__uncategorized__"
if cat_id not in doc_map:
doc_map[cat_id] = []
doc_map[cat_id].append(doc)
# 递归渲染目录 + 文档(按目录顺序)
section_counter = [0]
def render_section(nodes: list, level: int = 0) -> tuple[str, str]:
"""返回 (内容HTML, 目录HTML)"""
content_html = ""
toc_html = ""
for node in nodes:
section_counter[0] += 1
section_id = f"section-{section_counter[0]}"
icon = "📁" if node["is_folder"] else "📄"
heading_tag = "h2" if level == 0 else "h3" if level == 1 else "h4"
indent = " " * level
# 目录项
doc_count = f" ({node['doc_count']}篇)" if node["doc_count"] > 0 else ""
toc_html += f'{indent}<div class="toc-item" style="padding-left: {level * 16}px">'
toc_html += f'<a href="#{section_id}">{icon} {node["name"]}</a>{doc_count}</div>\n'
# 内容标题
content_html += f'<{heading_tag} id="{section_id}">{icon} {node["name"]}</{heading_tag}>\n'
# 该目录下的文档
cat_docs = doc_map.get(node["id"], [])
for doc in cat_docs:
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
doc_url = f"/k/{token}/doc/{doc_token}"
summary = doc.content_summary or ""
keywords = doc.keywords or ""
status_badge = f' <span style="color:#e67e22;font-size:0.8em">[{doc.status}]</span>' if doc.status != "READY" else ""
content_html += f'<div class="doc-item">\n'
content_html += f' <h4><a href="{doc_url}">{doc.title or doc.original_filename}</a>{status_badge}</h4>\n'
content_html += f' <p class="meta">类型:{doc.file_ext} | 更新:{doc.updated_at}</p>\n'
if summary:
content_html += f' <p class="summary">{summary}</p>\n'
if keywords:
content_html += f' <p class="keywords">关键词:{keywords}</p>\n'
content_html += f'</div>\n'
# 子目录
if node.get("children"):
child_content, child_toc = render_section(node["children"], level + 1)
content_html += child_content
toc_html += child_toc
return content_html, toc_html
all_content, all_toc = render_section(category_tree)
# 未分类文档
uncategorized = doc_map.get("__uncategorized__", [])
if uncategorized:
all_content += '<h2 id="uncategorized">📄 未分类文档</h2>\n'
for doc in uncategorized:
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
doc_url = f"/k/{token}/doc/{doc_token}"
all_content += f'<div class="doc-item"><h4><a href="{doc_url}">{doc.title or doc.original_filename}</a></h4></div>\n'
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{kb.name}</title>
{_robots_meta()}
{_referrer_meta()}
<style>
:root {{ --font-size: 15px; }}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: var(--font-size); line-height: 1.8; color: #333; }}
/* 手机端:单列布局 */
.container {{ display: flex; min-height: 100vh; }}
.sidebar {{ width: 260px; background: #f8f9fa; border-right: 1px solid #e4e7ed; padding: 20px; overflow-y: auto; position: fixed; height: 100vh; }}
.main {{ flex: 1; padding: 20px 30px; margin-left: 260px; max-width: 900px; }}
.sidebar h2 {{ font-size: 16px; margin-bottom: 15px; color: #333; }}
.toc-item {{ padding: 6px 8px; border-radius: 4px; font-size: 14px; }}
.toc-item:hover {{ background: #e8e8e8; }}
.toc-item a {{ color: #333; text-decoration: none; }}
.toc-item a:hover {{ color: #409eff; }}
h1 {{ font-size: 24px; color: #333; border-bottom: 2px solid #409eff; padding-bottom: 10px; margin-bottom: 20px; }}
h2 {{ font-size: 20px; color: #333; margin: 30px 0 15px; padding-bottom: 8px; border-bottom: 1px solid #eee; }}
h3 {{ font-size: 18px; color: #333; margin: 25px 0 10px; }}
h4 {{ font-size: 16px; color: #333; margin: 15px 0 8px; }}
.doc-item {{ padding: 12px 0; border-bottom: 1px solid #f0f0f0; }}
.doc-item a {{ color: #0066cc; text-decoration: none; }}
.doc-item a:hover {{ text-decoration: underline; }}
.meta {{ color: #888; font-size: 0.9em; margin: 4px 0; }}
.summary {{ color: #555; font-size: 0.95em; margin: 6px 0; }}
.keywords {{ color: #888; font-size: 0.85em; }}
.footer {{ margin-top: 40px; padding: 20px 0; border-top: 1px solid #eee; color: #999; font-size: 0.9em; }}
/* 字体大小控制 */
.font-controls {{ position: fixed; bottom: 20px; right: 20px; background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 8px; display: flex; gap: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 100; }}
.font-controls button {{ width: 36px; height: 36px; border: 1px solid #ddd; border-radius: 4px; background: #fff; cursor: pointer; font-size: 16px; }}
.font-controls button:hover {{ background: #f5f5f5; }}
/* 手机端适配 */
@media (max-width: 768px) {{
.sidebar {{ display: none; }}
.main {{ margin-left: 0; padding: 15px; }}
h1 {{ font-size: 20px; }}
h2 {{ font-size: 18px; }}
h3 {{ font-size: 16px; }}
h4 {{ font-size: 15px; }}
.font-controls {{ bottom: 10px; right: 10px; }}
/* 手机端显示目录按钮 */
.mobile-toc {{ display: block; }}
.sidebar.mobile-show {{ display: block; position: fixed; top: 0; left: 0; width: 80%; max-width: 300px; z-index: 200; box-shadow: 2px 0 10px rgba(0,0,0,0.2); }}
.sidebar.mobile-show ~ .overlay {{ display: block; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 199; }}
}}
@media (min-width: 769px) {{
.mobile-toc {{ display: none; }}
.overlay {{ display: none; }}
}}
</style>
</head>
<body>
<div class="container">
<aside class="sidebar" id="sidebar">
<h2>📚 目录</h2>
{all_toc}
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #ddd;">
<a href="/k/{token}/search?q=" style="color: #0066cc; font-size: 14px;">🔍 搜索文档</a>
</div>
</aside>
<div class="overlay" id="overlay" onclick="document.getElementById('sidebar').classList.remove('mobile-show');this.style.display='none'"></div>
<main class="main">
<button class="mobile-toc" onclick="document.getElementById('sidebar').classList.add('mobile-show');document.getElementById('overlay').style.display='block'" style="margin-bottom: 15px; padding: 8px 16px; border: 1px solid #ddd; border-radius: 6px; background: #fff; cursor: pointer;">📚 目录</button>
<h1>{kb.name}</h1>
{"<p style='color:#666;margin-bottom:20px'>" + kb.description + "</p>" if kb.description else ""}
{all_content}
<div class="footer">
<p>本页为 AI 可读知识库页面,包含全部文档内容。</p>
<p><a href="/k/{token}.json" style="color:#0066cc">JSON 格式</a> | <a href="/k/{token}.md" style="color:#0066cc">Markdown 格式</a> | <a href="/k/{token}.txt" style="color:#0066cc">纯文本格式</a></p>
</div>
</main>
</div>
<!-- 字体大小控制 -->
<div class="font-controls">
<button onclick="changeFontSize(-1)" title="缩小字体">A-</button>
<button onclick="changeFontSize(1)" title="放大字体">A+</button>
</div>
<script>
function changeFontSize(delta) {{
const root = document.documentElement;
const current = parseInt(getComputedStyle(root).getPropertyValue('--font-size'));
const newSize = Math.max(12, Math.min(24, current + delta));
root.style.setProperty('--font-size', newSize + 'px');
localStorage.setItem('kb-font-size', newSize);
}}
// 恢复保存的字体大小
const saved = localStorage.getItem('kb-font-size');
if (saved) document.documentElement.style.setProperty('--font-size', saved + 'px');
</script>
</body>
</html>"""
return HTMLResponse(content=html)
# ============================================================
# 目录页面 - 显示某个目录下的文档
# ============================================================
@router.get("/{token}/category/{path:path}.json")
def category_json(
token: str,
path: str,
request: Request,
db: Session = Depends(get_db),
) -> Response:
"""目录下的文档列表(JSON)。
path 格式:01公司层/公司基本信息(不含前后斜杠)
"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
# 规范化路径
category_path = f"/{path.strip('/')}/"
cat = _get_category_by_path(db, kb.id, category_path)
if cat is None:
raise NotFoundError("目录不存在。")
_log_access(db, kb.id, f"/k/{token}/category/{path}.json", request, req_type="category_json")
# 获取该目录下的文档
docs, total = svc.list_documents(kb, category_id=cat.id)
doc_list = [] doc_list = []
for doc in docs: for doc in docs:
doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
doc_list.append({ doc_list.append({
"title": doc.title or doc.original_filename, "title": doc.title or doc.original_filename,
"file_type": doc.file_ext, "file_type": doc.file_ext,
@@ -151,17 +467,15 @@ def kb_index_json(
"summary": doc.content_summary, "summary": doc.content_summary,
"keywords": doc.keywords.split(",") if doc.keywords else [], "keywords": doc.keywords.split(",") if doc.keywords else [],
"updated_at": doc.updated_at, "updated_at": doc.updated_at,
"url": f"/k/{token}/doc/{doc_token}",
}) })
# 获取目录树
category_tree = svc.get_category_tree(kb)
data = { data = {
"name": kb.name, "category": cat.name,
"description": kb.description, "category_path": cat.path,
"document_count": len(doc_list), "document_count": total,
"categories": category_tree,
"documents": doc_list, "documents": doc_list,
"back_to_index": f"/k/{token}.json",
} }
return Response( return Response(
@@ -170,140 +484,176 @@ def kb_index_json(
) )
@router.get("/{token}") @router.get("/{token}/category/{path:path}.md")
def kb_index_html( def category_markdown(
token: str, token: str,
path: str,
request: Request, request: Request,
category: str = Query(None, description="按分类路径过滤,如 /01公司层/公司基本信息/"),
page: int = Query(1, ge=1),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> HTMLResponse: ) -> PlainTextResponse:
"""知识库首页(HTML)。支持按目录过滤""" """目录下的文档列表(Markdown"""
_rate_limit(request, token) _rate_limit(request, token)
svc = KbPublicService(db) svc = KbPublicService(db)
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
_log_access(db, kb.id, f"/k/{token}", request, req_type="html")
# 获取目录树 category_path = f"/{path.strip('/')}/"
category_tree = svc.get_category_tree(kb) cat = _get_category_by_path(db, kb.id, category_path)
if cat is None:
raise NotFoundError("目录不存在。")
# 按分类过滤 _log_access(db, kb.id, f"/k/{token}/category/{path}.md", request, req_type="category_md")
category_id = None
if category:
# 根据路径查找分类 ID
from sqlalchemy import select
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb.id,
DocumentCategory.path == category,
)
cat = db.scalars(stmt).first()
if cat:
category_id = cat.id
docs, total = svc.list_documents(kb, category_id=category_id, page=page, page_size=50) docs, _ = svc.list_documents(kb, category_id=cat.id)
# 渲染目录树侧边栏 lines = [f"# {cat.name}", ""]
def render_tree(nodes: list, level: int = 0) -> str: lines.append(f"知识库:{kb.name}")
html = "" lines.append("")
for node in nodes:
indent = " " * level
is_active = category == node["path"]
active_class = ' class="active"' if is_active else ""
doc_count = f' <span class="count">({node["doc_count"]})</span>' if node["doc_count"] > 0 else ""
if node["is_folder"]: if docs:
html += f'{indent}<li{active_class}><a href="/k/{token}?category={node["path"]}">{node["name"]}</a>{doc_count}</li>\n' lines.append("## 文档列表")
if node["children"]: lines.append("")
html += f'{indent}<ul>\n{render_tree(node["children"], level + 1)}{indent}</ul>\n' for doc in docs:
else: title = doc.title or doc.original_filename
html += f'{indent}<li{active_class}><a href="/k/{token}?category={node["path"]}">{node["name"]}</a>{doc_count}</li>\n' lines.append(f"### {title}")
return html if doc.description:
lines.append(f"- 描述:{doc.description}")
if doc.keywords:
lines.append(f"- 关键词:{doc.keywords}")
if doc.content_summary:
lines.append(f"- 摘要:{doc.content_summary}")
lines.append(f"- 更新:{doc.updated_at}")
lines.append("")
else:
lines.append("暂无文档。")
tree_html = render_tree(category_tree) return PlainTextResponse(content="\n".join(lines), media_type="text/markdown")
@router.get("/{token}/category/{path:path}.txt")
def category_text(
token: str,
path: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""目录下的文档列表(纯文本)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
category_path = f"/{path.strip('/')}/"
cat = _get_category_by_path(db, kb.id, category_path)
if cat is None:
raise NotFoundError("目录不存在。")
_log_access(db, kb.id, f"/k/{token}/category/{path}.txt", request, req_type="category_txt")
docs, _ = svc.list_documents(kb, category_id=cat.id)
lines = [cat.name, "=" * len(cat.name), ""]
lines.append(f"知识库:{kb.name}")
lines.append("")
if docs:
for i, doc in enumerate(docs, 1):
title = doc.title or doc.original_filename
lines.append(f"{i}. {title}")
if doc.description:
lines.append(f" 描述:{doc.description}")
if doc.keywords:
lines.append(f" 关键词:{doc.keywords}")
lines.append("")
else:
lines.append("暂无文档。")
return PlainTextResponse(content="\n".join(lines), media_type="text/plain")
@router.get("/{token}/category/{path:path}")
def category_html(
token: str,
path: str,
request: Request,
db: Session = Depends(get_db),
) -> HTMLResponse:
"""目录下的文档列表(HTML)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
category_path = f"/{path.strip('/')}/"
cat = _get_category_by_path(db, kb.id, category_path)
if cat is None:
raise NotFoundError("目录不存在。")
_log_access(db, kb.id, f"/k/{token}/category/{path}", request, req_type="category_html")
docs, total = svc.list_documents(kb, category_id=cat.id)
doc_rows = "" doc_rows = ""
for doc in docs: for doc in docs:
doc_url = f"/k/{token}/doc/{decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ''}" doc_token = decrypt_token(doc.doc_token_encrypted) if doc.doc_token_encrypted else ""
keywords_html = f'<span class="keywords">关键词:{doc.keywords}</span>' if doc.keywords else "" doc_url = f"/k/{token}/doc/{doc_token}"
keywords_html = f'<p class="keywords">关键词:{doc.keywords}</p>' if doc.keywords else ""
summary_html = f'<p class="summary">{doc.content_summary or ""}</p>' if doc.content_summary else "" summary_html = f'<p class="summary">{doc.content_summary or ""}</p>' if doc.content_summary else ""
status_badge = ""
if doc.status != "READY":
status_badge = f' <span class="status-badge">[{doc.status}]</span>'
doc_rows += f""" doc_rows += f"""
<div class="doc-item"> <div class="doc-item">
<h3><a href="{doc_url}">{doc.title or doc.original_filename}</a>{status_badge}</h3> <h3><a href="{doc_url}">{doc.title or doc.original_filename}</a></h3>
<p class="meta">类型:{doc.file_ext} | 更新:{doc.updated_at}</p> <p class="meta">类型:{doc.file_ext} | 更新:{doc.updated_at}</p>
{summary_html} {summary_html}
{keywords_html} {keywords_html}
</div> </div>
""" """
total_pages = (total + 49) // 50
pagination = ""
if total_pages > 1:
pagination = f'<p class="pagination">第 {page} / {total_pages} 页</p>'
html = f"""<!DOCTYPE html> html = f"""<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{kb.name}</title> <title>{cat.name} - {kb.name}</title>
{_robots_meta()} {_robots_meta()}
{_referrer_meta()} {_referrer_meta()}
<style> <style>
* {{ box-sizing: border-box; }} * {{ box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; line-height: 1.6; display: flex; gap: 30px; }} body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 30px; line-height: 1.8; }}
.sidebar {{ width: 280px; flex-shrink: 0; }} h1 {{ color: #333; }}
.main {{ flex: 1; max-width: 800px; }} .breadcrumb {{ color: #666; font-size: 14px; margin-bottom: 20px; }}
h1 {{ color: #333; margin-top: 0; }} .breadcrumb a {{ color: #0066cc; text-decoration: none; }}
.tree {{ list-style: none; padding: 0; margin: 0; }} .breadcrumb a:hover {{ text-decoration: underline; }}
.tree ul {{ list-style: none; padding-left: 20px; margin: 0; }} .doc-item {{ border-bottom: 1px solid #eee; padding: 20px 0; }}
.tree li {{ padding: 6px 10px; border-radius: 4px; }} .doc-item h3 {{ margin: 0 0 8px 0; }}
.tree li:hover {{ background: #f5f7fa; }} .doc-item a {{ color: #0066cc; text-decoration: none; font-size: 16px; }}
.tree li.active {{ background: #ecf5ff; }}
.tree a {{ color: #333; text-decoration: none; font-size: 0.95em; }}
.tree a:hover {{ color: #409eff; }}
.tree .count {{ color: #999; font-size: 0.85em; }}
.doc-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }}
.doc-item h3 {{ margin: 0 0 5px 0; }}
.doc-item a {{ color: #0066cc; text-decoration: none; }}
.doc-item a:hover {{ text-decoration: underline; }} .doc-item a:hover {{ text-decoration: underline; }}
.meta {{ color: #666; font-size: 0.9em; margin: 5px 0; }} .meta {{ color: #666; font-size: 14px; margin: 5px 0; }}
.summary {{ color: #444; font-size: 0.95em; margin: 5px 0; }} .summary {{ color: #444; font-size: 14px; margin: 8px 0; }}
.keywords {{ color: #888; font-size: 0.85em; }} .keywords {{ color: #888; font-size: 13px; }}
.pagination {{ color: #666; font-size: 0.9em; text-align: center; }} .footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; color: #999; font-size: 14px; }}
.status-badge {{ color: #e67e22; font-size: 0.8em; font-weight: normal; }} .footer a {{ color: #0066cc; }}
.footer {{ margin-top: 30px; padding-top: 15px; border-top: 1px solid #eee; color: #999; font-size: 0.85em; }}
.back-link {{ display: inline-block; margin-bottom: 15px; color: #0066cc; text-decoration: none; }}
.back-link:hover {{ text-decoration: underline; }}
</style> </style>
</head> </head>
<body> <body>
<div class="sidebar"> <div class="breadcrumb">
<h2 style="margin-top: 0; font-size: 1.1em;">目录</h2> <a href="/k/{token}">📚 {kb.name}</a> &gt; {cat.name}
<ul class="tree">
<li{" class='active'" if not category else ""}><a href="/k/{token}">全部文档</a> <span class="count">({total})</span></li>
{tree_html}
</ul>
</div> </div>
<div class="main">
<h1>{kb.name}</h1> <h1>{cat.name}</h1>
{"<p>" + kb.description + "</p>" if kb.description else ""} <p style="color: #666;">共 {total} 篇文档</p>
{"<a href='/k/" + token + "' class='back-link'>← 返回全部文档</a>" if category else ""}
<h2>{"当前分类:" + category if category else "文档列表"}</h2> {doc_rows if doc_rows else "<p style='color: #999;'>该目录暂无文档。</p>"}
{doc_rows if doc_rows else "<p>暂无文档。</p>"}
{pagination} <div class="footer">
<div class="footer"> <p><a href="/k/{token}">← 返回目录索引</a></p>
<p>This page is an AI-readable knowledge base index. Use the document links above to retrieve specific documents.</p> <p>This is an AI-readable document list. Click document links to view content.</p>
<p>本页为 AI 可读知识库目录,请通过上述文档链接获取具体内容。</p> <p>本页为 AI 可读文档列表。请点击文档链接查看具体内容。</p>
</div>
</div> </div>
</body> </body>
</html>""" </html>"""
return HTMLResponse(content=html) return HTMLResponse(content=html)
# --- 单文档访问 --- # ============================================================
# 单文档访问
# ============================================================
@router.get("/{token}/doc/{doc_token}.md") @router.get("/{token}/doc/{doc_token}.md")
@@ -313,14 +663,13 @@ def doc_page_markdown(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> PlainTextResponse: ) -> PlainTextResponse:
"""文档(Markdown)。""" """文档内容Markdown)。"""
_rate_limit(request, token) _rate_limit(request, token)
svc = KbPublicService(db) svc = KbPublicService(db)
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token) doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md") _log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md")
markdown_content = svc.get_document_content(doc) return PlainTextResponse(content=svc.get_document_content(doc), media_type="text/markdown")
return PlainTextResponse(content=markdown_content, media_type="text/markdown")
@router.get("/{token}/doc/{doc_token}.txt") @router.get("/{token}/doc/{doc_token}.txt")
@@ -330,15 +679,14 @@ def doc_page_text(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> PlainTextResponse: ) -> PlainTextResponse:
"""文档(纯文本)。""" """文档内容(纯文本)。"""
_rate_limit(request, token) _rate_limit(request, token)
svc = KbPublicService(db) svc = KbPublicService(db)
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token) doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt") _log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt")
markdown_content = svc.get_document_content(doc) content = svc.get_document_content(doc)
import re text = re.sub(r"[#*_`\[\]()>]", "", content)
text = re.sub(r"[#*_`\[\]()>]", "", markdown_content)
text = re.sub(r"\n{3,}", "\n\n", text) text = re.sub(r"\n{3,}", "\n\n", text)
return PlainTextResponse(content=text.strip(), media_type="text/plain") return PlainTextResponse(content=text.strip(), media_type="text/plain")
@@ -350,18 +698,29 @@ def doc_page_html(
request: Request = None, request: Request = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> HTMLResponse: ) -> HTMLResponse:
"""文档页面HTML)。""" """文档内容HTML)。"""
_rate_limit(request, token) _rate_limit(request, token)
svc = KbPublicService(db) svc = KbPublicService(db)
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token) doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}", request, doc_id=doc.id, req_type="doc_html") _log_access(db, kb.id, f"/k/{token}/doc/{doc_token}", request, doc_id=doc.id, req_type="doc_html")
markdown_content = svc.get_document_content(doc)
# Markdown → HTML(简单转换) content = svc.get_document_content(doc)
html_content = _markdown_to_html(markdown_content) html_content = _markdown_to_html(content)
# 获取文档所属目录
category_name = ""
category_path = ""
if doc.category_id:
cat = db.get(DocumentCategory, doc.category_id)
if cat:
category_name = cat.name
category_path = cat.path
keywords_html = f"<p><strong>关键词:</strong>{doc.keywords}</p>" if doc.keywords else "" keywords_html = f"<p><strong>关键词:</strong>{doc.keywords}</p>" if doc.keywords else ""
breadcrumb = f'<a href="/k/{token}">📚 {kb.name}</a>'
if category_name:
breadcrumb += f' &gt; <a href="/k/{token}/category{category_path}">{category_name}</a>'
html = f"""<!DOCTYPE html> html = f"""<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
@@ -371,9 +730,12 @@ def doc_page_html(
{_robots_meta()} {_robots_meta()}
{_referrer_meta()} {_referrer_meta()}
<style> <style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.8; }} body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 30px; line-height: 1.8; }}
h1 {{ color: #333; }} h1 {{ color: #333; }}
.meta {{ color: #666; font-size: 0.9em; margin-bottom: 20px; }} .breadcrumb {{ color: #666; font-size: 14px; margin-bottom: 20px; }}
.breadcrumb a {{ color: #0066cc; text-decoration: none; }}
.breadcrumb a:hover {{ text-decoration: underline; }}
.meta {{ color: #666; font-size: 14px; margin-bottom: 20px; }}
.content {{ margin-top: 20px; }} .content {{ margin-top: 20px; }}
.content h1, .content h2, .content h3 {{ color: #333; }} .content h1, .content h2, .content h3 {{ color: #333; }}
.content pre {{ background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto; }} .content pre {{ background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto; }}
@@ -381,11 +743,13 @@ def doc_page_html(
.content table {{ border-collapse: collapse; width: 100%; }} .content table {{ border-collapse: collapse; width: 100%; }}
.content th, .content td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }} .content th, .content td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
.content th {{ background: #f5f5f5; }} .content th {{ background: #f5f5f5; }}
.back {{ margin-top: 30px; }} .footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; color: #999; font-size: 14px; }}
.back a {{ color: #0066cc; }} .footer a {{ color: #0066cc; }}
</style> </style>
</head> </head>
<body> <body>
<div class="breadcrumb">{breadcrumb} &gt; {doc.title or doc.original_filename}</div>
<h1>{doc.title or doc.original_filename}</h1> <h1>{doc.title or doc.original_filename}</h1>
<div class="meta"> <div class="meta">
<p>类型:{doc.file_ext} | 更新:{doc.updated_at}</p> <p>类型:{doc.file_ext} | 更新:{doc.updated_at}</p>
@@ -395,55 +759,17 @@ def doc_page_html(
<div class="content"> <div class="content">
{html_content} {html_content}
</div> </div>
<div class="back"> <div class="footer">
<a href="/k/{token}">← 返回知识库目录</a> <p><a href="/k/{token}">← 返回目录索引</a>{" | <a href='/k/" + token + "/category" + category_path + "'>← 返回" + category_name + "</a>" if category_name else ""}</p>
</div> </div>
</body> </body>
</html>""" </html>"""
return HTMLResponse(content=html) return HTMLResponse(content=html)
@router.get("/{token}/doc/{doc_token}.md") # ============================================================
def doc_page_markdown( # 搜索
token: str, # ============================================================
doc_token: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""文档(Markdown)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md")
markdown_content = svc.get_document_content(doc)
return PlainTextResponse(content=markdown_content, media_type="text/markdown")
@router.get("/{token}/doc/{doc_token}.txt")
def doc_page_text(
token: str,
doc_token: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""文档(纯文本)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt")
markdown_content = svc.get_document_content(doc)
# 去掉 Markdown 标记
import re
text = re.sub(r"[#*_`\[\]()>]", "", markdown_content)
text = re.sub(r"\n{3,}", "\n\n", text)
return PlainTextResponse(content=text.strip(), media_type="text/plain")
# --- 搜索 ---
@router.get("/{token}/search") @router.get("/{token}/search")
@@ -464,9 +790,11 @@ def search_html(
result_items = "" result_items = ""
for item in results: for item in results:
doc_url = f"/k/{token}/doc/{item['url_hint'] or ''}" doc_url = f"/k/{token}/doc/{item['url_hint'] or ''}"
category_path = item.get('category_path', '')
category_badge = f' <span style="color:#999;font-size:12px">[{category_path}]</span>' if category_path else ""
result_items += f""" result_items += f"""
<div class="result-item"> <div class="result-item">
<h3><a href="{doc_url}">{item['title']}</a></h3> <h3><a href="{doc_url}">{item['title']}</a>{category_badge}</h3>
<p class="meta">类型:{item['file_type']} | 更新:{item['updated_at']}</p> <p class="meta">类型:{item['file_type']} | 更新:{item['updated_at']}</p>
{"<p>" + (item.get('description') or '') + "</p>"} {"<p>" + (item.get('description') or '') + "</p>"}
</div> </div>
@@ -480,17 +808,17 @@ def search_html(
{_robots_meta()} {_robots_meta()}
{_referrer_meta()} {_referrer_meta()}
<style> <style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }} body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 30px; line-height: 1.6; }}
.result-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }} .result-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }}
.result-item a {{ color: #0066cc; text-decoration: none; }} .result-item a {{ color: #0066cc; text-decoration: none; font-size: 16px; }}
.meta {{ color: #666; font-size: 0.9em; }} .meta {{ color: #666; font-size: 14px; }}
</style> </style>
</head> </head>
<body> <body>
<h1>搜索:{q}</h1> <h1>搜索:{q}</h1>
<p>共找到 {total} 个结果</p> <p>共找到 {total} 个结果</p>
{result_items if result_items else "<p>未找到相关文档。</p>"} {result_items if result_items else "<p>未找到相关文档。</p>"}
<p><a href="/k/{token}">← 返回知识库目录</a></p> <p><a href="/k/{token}">← 返回目录索引</a></p>
</body> </body>
</html>""" </html>"""
return HTMLResponse(content=html) return HTMLResponse(content=html)
@@ -521,31 +849,22 @@ def search_json(
) )
# ============================================================
# 工具函数
# ============================================================
def _markdown_to_html(markdown: str) -> str: def _markdown_to_html(markdown: str) -> str:
"""简单 Markdown → HTML 转换(安全处理)。""" """简单 Markdown → HTML 转换(安全处理)。"""
import re
# 转义 HTML 特殊字符
html = markdown.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") html = markdown.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# 标题
html = re.sub(r"^#### (.+)$", r"<h4>\1</h4>", html, flags=re.MULTILINE) html = re.sub(r"^#### (.+)$", r"<h4>\1</h4>", html, flags=re.MULTILINE)
html = re.sub(r"^### (.+)$", r"<h3>\1</h3>", html, flags=re.MULTILINE) html = re.sub(r"^### (.+)$", r"<h3>\1</h3>", html, flags=re.MULTILINE)
html = re.sub(r"^## (.+)$", r"<h2>\1</h2>", html, flags=re.MULTILINE) html = re.sub(r"^## (.+)$", r"<h2>\1</h2>", html, flags=re.MULTILINE)
html = re.sub(r"^# (.+)$", r"<h1>\1</h1>", html, flags=re.MULTILINE) html = re.sub(r"^# (.+)$", r"<h1>\1</h1>", html, flags=re.MULTILINE)
# 粗体/斜体
html = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html) html = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html)
html = re.sub(r"\*(.+?)\*", r"<em>\1</em>", html) html = re.sub(r"\*(.+?)\*", r"<em>\1</em>", html)
# 代码块
html = re.sub(r"```[\s\S]*?```", lambda m: f"<pre><code>{m.group(0)[3:-3]}</code></pre>", html) html = re.sub(r"```[\s\S]*?```", lambda m: f"<pre><code>{m.group(0)[3:-3]}</code></pre>", html)
# 行内代码
html = re.sub(r"`([^`]+)`", r"<code>\1</code>", html) html = re.sub(r"`([^`]+)`", r"<code>\1</code>", html)
# 段落(双换行 → <p>
html = re.sub(r"\n\n+", "</p><p>", html) html = re.sub(r"\n\n+", "</p><p>", html)
html = f"<p>{html}</p>" html = f"<p>{html}</p>"
return html return html
+4 -1
View File
@@ -94,7 +94,10 @@ def test_kb_index_json() -> None:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["name"] == "测试知识库" assert data["name"] == "测试知识库"
assert "documents" in data assert "categories" in data
# 每个目录有 documents 字段
if data["categories"]:
assert "documents" in data["categories"][0]
# 安全:不包含内部字段 # 安全:不包含内部字段
assert "user_id" not in data assert "user_id" not in data
assert "token_hash" not in data assert "token_hash" not in data
+44 -14
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import apiClient from '@/api/client' import apiClient from '@/api/client'
@@ -7,17 +7,23 @@ import apiClient from '@/api/client'
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const isCollapsed = ref(false) const isCollapsed = ref(false)
const isMobile = ref(window.innerWidth <= 768)
const showMobileMenu = ref(false)
onMounted(async () => { onMounted(async () => {
try { try {
const { data } = await apiClient.get('/auth/me') const { data } = await apiClient.get('/auth/me')
userStore.setUser(data) userStore.setUser(data)
} catch { } catch {
// 未登录,跳转登录页
if (router.currentRoute.value.path !== '/login' && router.currentRoute.value.path !== '/register') { if (router.currentRoute.value.path !== '/login' && router.currentRoute.value.path !== '/register') {
router.push('/login') router.push('/login')
} }
} }
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth <= 768
if (!isMobile.value) showMobileMenu.value = false
})
}) })
async function handleLogout() { async function handleLogout() {
@@ -27,20 +33,22 @@ async function handleLogout() {
userStore.clearUser() userStore.clearUser()
router.push('/login') router.push('/login')
} }
function navigateTo(path: string) {
router.push(path)
showMobileMenu.value = false
}
</script> </script>
<template> <template>
<el-container style="min-height: 100vh"> <el-container style="min-height: 100vh">
<el-aside :width="isCollapsed ? '64px' : '220px'" style="transition: width 0.3s"> <!-- 桌面端侧边栏 -->
<el-aside v-if="!isMobile" :width="isCollapsed ? '64px' : '220px'" style="transition: width 0.3s">
<div style="padding: 20px; text-align: center; font-weight: bold; font-size: 18px; color: #409eff; white-space: nowrap; overflow: hidden"> <div style="padding: 20px; text-align: center; font-weight: bold; font-size: 18px; color: #409eff; white-space: nowrap; overflow: hidden">
<span v-if="!isCollapsed">AI Knowledge Link</span> <span v-if="!isCollapsed">AI Knowledge Link</span>
<span v-else>AKL</span> <span v-else>AKL</span>
</div> </div>
<el-menu <el-menu :default-active="router.currentRoute.value.path" :collapse="isCollapsed" router>
:default-active="router.currentRoute.value.path"
:collapse="isCollapsed"
router
>
<el-menu-item index="/"> <el-menu-item index="/">
<el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg></el-icon> <el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg></el-icon>
<template #title>仪表盘</template> <template #title>仪表盘</template>
@@ -55,15 +63,37 @@ async function handleLogout() {
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-container> <el-container>
<el-header style="display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #eee"> <!-- 顶部栏 -->
<el-button :icon="isCollapsed ? 'Expand' : 'Fold'" text @click="isCollapsed = !isCollapsed" /> <el-header style="display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #eee; padding: 0 16px; height: 56px">
<div style="display: flex; align-items: center; gap: 16px"> <div style="display: flex; align-items: center; gap: 12px">
<span>{{ userStore.username }}</span> <!-- 手机端菜单按钮 -->
<el-button type="danger" text @click="handleLogout">退出登录</el-button> <el-button v-if="isMobile" @click="showMobileMenu = !showMobileMenu" text style="font-size: 20px"></el-button>
<!-- 桌面端折叠按钮 -->
<el-button v-else @click="isCollapsed = !isCollapsed" text>
{{ isCollapsed ? '→' : '←' }}
</el-button>
<span v-if="isMobile" style="font-weight: bold; color: #409eff; font-size: 16px">AKL</span>
</div>
<div style="display: flex; align-items: center; gap: 12px">
<span style="font-size: 14px">{{ userStore.username }}</span>
<el-button type="danger" text @click="handleLogout" style="font-size: 14px">退出</el-button>
</div> </div>
</el-header> </el-header>
<el-main style="padding: 20px">
<!-- 手机端抽屉菜单 -->
<div v-if="isMobile && showMobileMenu" style="position: fixed; top: 56px; left: 0; right: 0; bottom: 0; z-index: 999; background: rgba(0,0,0,0.5)" @click="showMobileMenu = false">
<div style="width: 260px; height: 100%; background: #fff; padding: 20px" @click.stop>
<div style="font-weight: bold; font-size: 18px; color: #409eff; margin-bottom: 20px">AI Knowledge Link</div>
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/')">📊 仪表盘</div>
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/knowledge-bases')">📚 知识库</div>
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/settings')"> 设置</div>
</div>
</div>
<!-- 主内容 -->
<el-main style="padding: 16px">
<router-view /> <router-view />
</el-main> </el-main>
</el-container> </el-container>
+270 -171
View File
@@ -5,17 +5,23 @@ import apiClient from '@/api/client';
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const isCollapsed = ref(false); const isCollapsed = ref(false);
const isMobile = ref(window.innerWidth <= 768);
const showMobileMenu = ref(false);
onMounted(async () => { onMounted(async () => {
try { try {
const { data } = await apiClient.get('/auth/me'); const { data } = await apiClient.get('/auth/me');
userStore.setUser(data); userStore.setUser(data);
} }
catch { catch {
// 未登录,跳转登录页
if (router.currentRoute.value.path !== '/login' && router.currentRoute.value.path !== '/register') { if (router.currentRoute.value.path !== '/login' && router.currentRoute.value.path !== '/register') {
router.push('/login'); router.push('/login');
} }
} }
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth <= 768;
if (!isMobile.value)
showMobileMenu.value = false;
});
}); });
async function handleLogout() { async function handleLogout() {
try { try {
@@ -25,6 +31,10 @@ async function handleLogout() {
userStore.clearUser(); userStore.clearUser();
router.push('/login'); router.push('/login');
} }
function navigateTo(path) {
router.push(path);
showMobileMenu.value = false;
}
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
const __VLS_ctx = {}; const __VLS_ctx = {};
let __VLS_components; let __VLS_components;
@@ -40,127 +50,129 @@ const __VLS_2 = __VLS_1({
}, ...__VLS_functionalComponentArgsRest(__VLS_1)); }, ...__VLS_functionalComponentArgsRest(__VLS_1));
var __VLS_4 = {}; var __VLS_4 = {};
__VLS_3.slots.default; __VLS_3.slots.default;
const __VLS_5 = {}.ElAside; if (!__VLS_ctx.isMobile) {
/** @type {[typeof __VLS_components.ElAside, typeof __VLS_components.elAside, typeof __VLS_components.ElAside, typeof __VLS_components.elAside, ]} */ ; const __VLS_5 = {}.ElAside;
// @ts-ignore /** @type {[typeof __VLS_components.ElAside, typeof __VLS_components.elAside, typeof __VLS_components.ElAside, typeof __VLS_components.elAside, ]} */ ;
const __VLS_6 = __VLS_asFunctionalComponent(__VLS_5, new __VLS_5({ // @ts-ignore
width: (__VLS_ctx.isCollapsed ? '64px' : '220px'), const __VLS_6 = __VLS_asFunctionalComponent(__VLS_5, new __VLS_5({
...{ style: {} }, width: (__VLS_ctx.isCollapsed ? '64px' : '220px'),
})); ...{ style: {} },
const __VLS_7 = __VLS_6({ }));
width: (__VLS_ctx.isCollapsed ? '64px' : '220px'), const __VLS_7 = __VLS_6({
...{ style: {} }, width: (__VLS_ctx.isCollapsed ? '64px' : '220px'),
}, ...__VLS_functionalComponentArgsRest(__VLS_6)); ...{ style: {} },
__VLS_8.slots.default; }, ...__VLS_functionalComponentArgsRest(__VLS_6));
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({ __VLS_8.slots.default;
...{ style: {} }, __VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
}); ...{ style: {} },
if (!__VLS_ctx.isCollapsed) { });
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({}); if (!__VLS_ctx.isCollapsed) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({});
}
else {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({});
}
const __VLS_9 = {}.ElMenu;
/** @type {[typeof __VLS_components.ElMenu, typeof __VLS_components.elMenu, typeof __VLS_components.ElMenu, typeof __VLS_components.elMenu, ]} */ ;
// @ts-ignore
const __VLS_10 = __VLS_asFunctionalComponent(__VLS_9, new __VLS_9({
defaultActive: (__VLS_ctx.router.currentRoute.value.path),
collapse: (__VLS_ctx.isCollapsed),
router: true,
}));
const __VLS_11 = __VLS_10({
defaultActive: (__VLS_ctx.router.currentRoute.value.path),
collapse: (__VLS_ctx.isCollapsed),
router: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_10));
__VLS_12.slots.default;
const __VLS_13 = {}.ElMenuItem;
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
// @ts-ignore
const __VLS_14 = __VLS_asFunctionalComponent(__VLS_13, new __VLS_13({
index: "/",
}));
const __VLS_15 = __VLS_14({
index: "/",
}, ...__VLS_functionalComponentArgsRest(__VLS_14));
__VLS_16.slots.default;
const __VLS_17 = {}.ElIcon;
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
// @ts-ignore
const __VLS_18 = __VLS_asFunctionalComponent(__VLS_17, new __VLS_17({}));
const __VLS_19 = __VLS_18({}, ...__VLS_functionalComponentArgsRest(__VLS_18));
__VLS_20.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
viewBox: "0 0 24 24",
fill: "currentColor",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
d: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z",
});
var __VLS_20;
{
const { title: __VLS_thisSlot } = __VLS_16.slots;
}
var __VLS_16;
const __VLS_21 = {}.ElMenuItem;
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
// @ts-ignore
const __VLS_22 = __VLS_asFunctionalComponent(__VLS_21, new __VLS_21({
index: "/knowledge-bases",
}));
const __VLS_23 = __VLS_22({
index: "/knowledge-bases",
}, ...__VLS_functionalComponentArgsRest(__VLS_22));
__VLS_24.slots.default;
const __VLS_25 = {}.ElIcon;
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
// @ts-ignore
const __VLS_26 = __VLS_asFunctionalComponent(__VLS_25, new __VLS_25({}));
const __VLS_27 = __VLS_26({}, ...__VLS_functionalComponentArgsRest(__VLS_26));
__VLS_28.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
viewBox: "0 0 24 24",
fill: "currentColor",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
d: "M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z",
});
var __VLS_28;
{
const { title: __VLS_thisSlot } = __VLS_24.slots;
}
var __VLS_24;
const __VLS_29 = {}.ElMenuItem;
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
// @ts-ignore
const __VLS_30 = __VLS_asFunctionalComponent(__VLS_29, new __VLS_29({
index: "/settings",
}));
const __VLS_31 = __VLS_30({
index: "/settings",
}, ...__VLS_functionalComponentArgsRest(__VLS_30));
__VLS_32.slots.default;
const __VLS_33 = {}.ElIcon;
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
// @ts-ignore
const __VLS_34 = __VLS_asFunctionalComponent(__VLS_33, new __VLS_33({}));
const __VLS_35 = __VLS_34({}, ...__VLS_functionalComponentArgsRest(__VLS_34));
__VLS_36.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
viewBox: "0 0 24 24",
fill: "currentColor",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z",
});
var __VLS_36;
{
const { title: __VLS_thisSlot } = __VLS_32.slots;
}
var __VLS_32;
var __VLS_12;
var __VLS_8;
} }
else {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({});
}
const __VLS_9 = {}.ElMenu;
/** @type {[typeof __VLS_components.ElMenu, typeof __VLS_components.elMenu, typeof __VLS_components.ElMenu, typeof __VLS_components.elMenu, ]} */ ;
// @ts-ignore
const __VLS_10 = __VLS_asFunctionalComponent(__VLS_9, new __VLS_9({
defaultActive: (__VLS_ctx.router.currentRoute.value.path),
collapse: (__VLS_ctx.isCollapsed),
router: true,
}));
const __VLS_11 = __VLS_10({
defaultActive: (__VLS_ctx.router.currentRoute.value.path),
collapse: (__VLS_ctx.isCollapsed),
router: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_10));
__VLS_12.slots.default;
const __VLS_13 = {}.ElMenuItem;
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
// @ts-ignore
const __VLS_14 = __VLS_asFunctionalComponent(__VLS_13, new __VLS_13({
index: "/",
}));
const __VLS_15 = __VLS_14({
index: "/",
}, ...__VLS_functionalComponentArgsRest(__VLS_14));
__VLS_16.slots.default;
const __VLS_17 = {}.ElIcon;
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
// @ts-ignore
const __VLS_18 = __VLS_asFunctionalComponent(__VLS_17, new __VLS_17({}));
const __VLS_19 = __VLS_18({}, ...__VLS_functionalComponentArgsRest(__VLS_18));
__VLS_20.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
viewBox: "0 0 24 24",
fill: "currentColor",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
d: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z",
});
var __VLS_20;
{
const { title: __VLS_thisSlot } = __VLS_16.slots;
}
var __VLS_16;
const __VLS_21 = {}.ElMenuItem;
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
// @ts-ignore
const __VLS_22 = __VLS_asFunctionalComponent(__VLS_21, new __VLS_21({
index: "/knowledge-bases",
}));
const __VLS_23 = __VLS_22({
index: "/knowledge-bases",
}, ...__VLS_functionalComponentArgsRest(__VLS_22));
__VLS_24.slots.default;
const __VLS_25 = {}.ElIcon;
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
// @ts-ignore
const __VLS_26 = __VLS_asFunctionalComponent(__VLS_25, new __VLS_25({}));
const __VLS_27 = __VLS_26({}, ...__VLS_functionalComponentArgsRest(__VLS_26));
__VLS_28.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
viewBox: "0 0 24 24",
fill: "currentColor",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
d: "M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z",
});
var __VLS_28;
{
const { title: __VLS_thisSlot } = __VLS_24.slots;
}
var __VLS_24;
const __VLS_29 = {}.ElMenuItem;
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
// @ts-ignore
const __VLS_30 = __VLS_asFunctionalComponent(__VLS_29, new __VLS_29({
index: "/settings",
}));
const __VLS_31 = __VLS_30({
index: "/settings",
}, ...__VLS_functionalComponentArgsRest(__VLS_30));
__VLS_32.slots.default;
const __VLS_33 = {}.ElIcon;
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
// @ts-ignore
const __VLS_34 = __VLS_asFunctionalComponent(__VLS_33, new __VLS_33({}));
const __VLS_35 = __VLS_34({}, ...__VLS_functionalComponentArgsRest(__VLS_34));
__VLS_36.slots.default;
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
viewBox: "0 0 24 24",
fill: "currentColor",
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z",
});
var __VLS_36;
{
const { title: __VLS_thisSlot } = __VLS_32.slots;
}
var __VLS_32;
var __VLS_12;
var __VLS_8;
const __VLS_37 = {}.ElContainer; const __VLS_37 = {}.ElContainer;
/** @type {[typeof __VLS_components.ElContainer, typeof __VLS_components.elContainer, typeof __VLS_components.ElContainer, typeof __VLS_components.elContainer, ]} */ ; /** @type {[typeof __VLS_components.ElContainer, typeof __VLS_components.elContainer, typeof __VLS_components.ElContainer, typeof __VLS_components.elContainer, ]} */ ;
// @ts-ignore // @ts-ignore
@@ -177,71 +189,155 @@ const __VLS_43 = __VLS_42({
...{ style: {} }, ...{ style: {} },
}, ...__VLS_functionalComponentArgsRest(__VLS_42)); }, ...__VLS_functionalComponentArgsRest(__VLS_42));
__VLS_44.slots.default; __VLS_44.slots.default;
const __VLS_45 = {}.ElButton;
/** @type {[typeof __VLS_components.ElButton, typeof __VLS_components.elButton, ]} */ ;
// @ts-ignore
const __VLS_46 = __VLS_asFunctionalComponent(__VLS_45, new __VLS_45({
...{ 'onClick': {} },
icon: (__VLS_ctx.isCollapsed ? 'Expand' : 'Fold'),
text: true,
}));
const __VLS_47 = __VLS_46({
...{ 'onClick': {} },
icon: (__VLS_ctx.isCollapsed ? 'Expand' : 'Fold'),
text: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_46));
let __VLS_49;
let __VLS_50;
let __VLS_51;
const __VLS_52 = {
onClick: (...[$event]) => {
__VLS_ctx.isCollapsed = !__VLS_ctx.isCollapsed;
}
};
var __VLS_48;
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({ __VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ style: {} }, ...{ style: {} },
}); });
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({}); if (__VLS_ctx.isMobile) {
const __VLS_45 = {}.ElButton;
/** @type {[typeof __VLS_components.ElButton, typeof __VLS_components.elButton, typeof __VLS_components.ElButton, typeof __VLS_components.elButton, ]} */ ;
// @ts-ignore
const __VLS_46 = __VLS_asFunctionalComponent(__VLS_45, new __VLS_45({
...{ 'onClick': {} },
text: true,
...{ style: {} },
}));
const __VLS_47 = __VLS_46({
...{ 'onClick': {} },
text: true,
...{ style: {} },
}, ...__VLS_functionalComponentArgsRest(__VLS_46));
let __VLS_49;
let __VLS_50;
let __VLS_51;
const __VLS_52 = {
onClick: (...[$event]) => {
if (!(__VLS_ctx.isMobile))
return;
__VLS_ctx.showMobileMenu = !__VLS_ctx.showMobileMenu;
}
};
__VLS_48.slots.default;
var __VLS_48;
}
else {
const __VLS_53 = {}.ElButton;
/** @type {[typeof __VLS_components.ElButton, typeof __VLS_components.elButton, typeof __VLS_components.ElButton, typeof __VLS_components.elButton, ]} */ ;
// @ts-ignore
const __VLS_54 = __VLS_asFunctionalComponent(__VLS_53, new __VLS_53({
...{ 'onClick': {} },
text: true,
}));
const __VLS_55 = __VLS_54({
...{ 'onClick': {} },
text: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_54));
let __VLS_57;
let __VLS_58;
let __VLS_59;
const __VLS_60 = {
onClick: (...[$event]) => {
if (!!(__VLS_ctx.isMobile))
return;
__VLS_ctx.isCollapsed = !__VLS_ctx.isCollapsed;
}
};
__VLS_56.slots.default;
(__VLS_ctx.isCollapsed ? '→' : '←');
var __VLS_56;
}
if (__VLS_ctx.isMobile) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ style: {} },
});
}
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ style: {} },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({
...{ style: {} },
});
(__VLS_ctx.userStore.username); (__VLS_ctx.userStore.username);
const __VLS_53 = {}.ElButton; const __VLS_61 = {}.ElButton;
/** @type {[typeof __VLS_components.ElButton, typeof __VLS_components.elButton, typeof __VLS_components.ElButton, typeof __VLS_components.elButton, ]} */ ; /** @type {[typeof __VLS_components.ElButton, typeof __VLS_components.elButton, typeof __VLS_components.ElButton, typeof __VLS_components.elButton, ]} */ ;
// @ts-ignore // @ts-ignore
const __VLS_54 = __VLS_asFunctionalComponent(__VLS_53, new __VLS_53({
...{ 'onClick': {} },
type: "danger",
text: true,
}));
const __VLS_55 = __VLS_54({
...{ 'onClick': {} },
type: "danger",
text: true,
}, ...__VLS_functionalComponentArgsRest(__VLS_54));
let __VLS_57;
let __VLS_58;
let __VLS_59;
const __VLS_60 = {
onClick: (__VLS_ctx.handleLogout)
};
__VLS_56.slots.default;
var __VLS_56;
var __VLS_44;
const __VLS_61 = {}.ElMain;
/** @type {[typeof __VLS_components.ElMain, typeof __VLS_components.elMain, typeof __VLS_components.ElMain, typeof __VLS_components.elMain, ]} */ ;
// @ts-ignore
const __VLS_62 = __VLS_asFunctionalComponent(__VLS_61, new __VLS_61({ const __VLS_62 = __VLS_asFunctionalComponent(__VLS_61, new __VLS_61({
...{ 'onClick': {} },
type: "danger",
text: true,
...{ style: {} }, ...{ style: {} },
})); }));
const __VLS_63 = __VLS_62({ const __VLS_63 = __VLS_62({
...{ 'onClick': {} },
type: "danger",
text: true,
...{ style: {} }, ...{ style: {} },
}, ...__VLS_functionalComponentArgsRest(__VLS_62)); }, ...__VLS_functionalComponentArgsRest(__VLS_62));
let __VLS_65;
let __VLS_66;
let __VLS_67;
const __VLS_68 = {
onClick: (__VLS_ctx.handleLogout)
};
__VLS_64.slots.default; __VLS_64.slots.default;
const __VLS_65 = {}.RouterView; var __VLS_64;
var __VLS_44;
if (__VLS_ctx.isMobile && __VLS_ctx.showMobileMenu) {
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ onClick: (...[$event]) => {
if (!(__VLS_ctx.isMobile && __VLS_ctx.showMobileMenu))
return;
__VLS_ctx.showMobileMenu = false;
} },
...{ style: {} },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ onClick: () => { } },
...{ style: {} },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ style: {} },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ onClick: (...[$event]) => {
if (!(__VLS_ctx.isMobile && __VLS_ctx.showMobileMenu))
return;
__VLS_ctx.navigateTo('/');
} },
...{ style: {} },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ onClick: (...[$event]) => {
if (!(__VLS_ctx.isMobile && __VLS_ctx.showMobileMenu))
return;
__VLS_ctx.navigateTo('/knowledge-bases');
} },
...{ style: {} },
});
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
...{ onClick: (...[$event]) => {
if (!(__VLS_ctx.isMobile && __VLS_ctx.showMobileMenu))
return;
__VLS_ctx.navigateTo('/settings');
} },
...{ style: {} },
});
}
const __VLS_69 = {}.ElMain;
/** @type {[typeof __VLS_components.ElMain, typeof __VLS_components.elMain, typeof __VLS_components.ElMain, typeof __VLS_components.elMain, ]} */ ;
// @ts-ignore
const __VLS_70 = __VLS_asFunctionalComponent(__VLS_69, new __VLS_69({
...{ style: {} },
}));
const __VLS_71 = __VLS_70({
...{ style: {} },
}, ...__VLS_functionalComponentArgsRest(__VLS_70));
__VLS_72.slots.default;
const __VLS_73 = {}.RouterView;
/** @type {[typeof __VLS_components.RouterView, typeof __VLS_components.routerView, ]} */ ; /** @type {[typeof __VLS_components.RouterView, typeof __VLS_components.routerView, ]} */ ;
// @ts-ignore // @ts-ignore
const __VLS_66 = __VLS_asFunctionalComponent(__VLS_65, new __VLS_65({})); const __VLS_74 = __VLS_asFunctionalComponent(__VLS_73, new __VLS_73({}));
const __VLS_67 = __VLS_66({}, ...__VLS_functionalComponentArgsRest(__VLS_66)); const __VLS_75 = __VLS_74({}, ...__VLS_functionalComponentArgsRest(__VLS_74));
var __VLS_64; var __VLS_72;
var __VLS_40; var __VLS_40;
var __VLS_3; var __VLS_3;
var __VLS_dollars; var __VLS_dollars;
@@ -251,7 +347,10 @@ const __VLS_self = (await import('vue')).defineComponent({
router: router, router: router,
userStore: userStore, userStore: userStore,
isCollapsed: isCollapsed, isCollapsed: isCollapsed,
isMobile: isMobile,
showMobileMenu: showMobileMenu,
handleLogout: handleLogout, handleLogout: handleLogout,
navigateTo: navigateTo,
}; };
}, },
}); });
+320 -151
View File
@@ -7,6 +7,14 @@ import apiClient from '@/api/client'
const route = useRoute() const route = useRoute()
const kbId = route.params.id as string const kbId = route.params.id as string
const isMobile = ref(window.innerWidth <= 768)
const showMobileSidebar = ref(false)
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth <= 768
if (!isMobile.value) showMobileSidebar.value = false
})
const kb = ref<any>(null) const kb = ref<any>(null)
const docs = ref<any[]>([]) const docs = ref<any[]>([])
const categories = ref<any[]>([]) const categories = ref<any[]>([])
@@ -247,159 +255,160 @@ function getCategoryName(catId: string) {
</script> </script>
<template> <template>
<div v-if="kb" style="display: flex; gap: 24px; height: calc(100vh - 120px)"> <div v-if="kb">
<!-- 左侧目录树 --> <!-- 手机端顶部操作栏 -->
<div style="width: 300px; flex-shrink: 0; overflow-y: auto; border-right: 1px solid #e4e7ed; padding-right: 20px"> <div class="mobile-header">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px"> <h1 style="margin: 0; font-size: 20px">{{ kb.name }}</h1>
<h3 style="margin: 0; font-size: 16px">📁 目录结构</h3> <div style="display: flex; gap: 8px; margin-top: 10px">
<el-button size="small" type="primary" @click="openAddCategory(null)">+ 新建</el-button> <el-button @click="handleCopyLink" size="small" style="flex: 1">📋 复制链接</el-button>
<el-button type="warning" @click="handleRegenerateLink" size="small" style="flex: 1">🔄 重置链接</el-button>
</div> </div>
<!-- 全部文档 -->
<div
style="padding: 10px 12px; cursor: pointer; border-radius: 6px; margin-bottom: 8px; font-size: 14px; transition: all 0.2s"
:style="{ background: !selectedCategoryId ? '#ecf5ff' : '#f5f7fa', color: !selectedCategoryId ? '#409eff' : '#333', fontWeight: !selectedCategoryId ? 'bold' : 'normal' }"
@click="clearCategoryFilter"
>
📋 全部文档
</div>
<!-- 目录树 -->
<el-tree
:data="categories"
node-key="id"
default-expand-all
:expand-on-click-node="false"
:props="{ children: 'children', label: 'name' }"
>
<template #default="{ node, data }">
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 6px 0">
<span
style="cursor: pointer; font-size: 14px; flex: 1"
:style="{ color: selectedCategoryId === data.id ? '#409eff' : '#333', fontWeight: selectedCategoryId === data.id ? 'bold' : 'normal' }"
@click="selectCategory(data)"
>
{{ data.is_folder ? '📁' : '📄' }} {{ data.name }}
<span v-if="data.doc_count > 0" style="color: #999; font-size: 12px; margin-left: 4px">({{ data.doc_count }})</span>
</span>
<span style="display: flex; gap: 2px">
<el-button size="small" text @click.stop="openAddCategory(data.id)" style="font-size: 12px">+</el-button>
<el-button size="small" text @click.stop="openEditCategory(data)" style="font-size: 12px"></el-button>
<el-button size="small" text type="danger" @click.stop="handleDeleteCategory(data)" style="font-size: 12px">×</el-button>
</span>
</div>
</template>
</el-tree>
</div> </div>
<!-- 右侧文档列表 --> <div class="main-layout">
<div style="flex: 1; overflow-y: auto"> <!-- 左侧目录树桌面端常驻手机端抽屉 -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px"> <aside class="sidebar" :class="{ 'mobile-show': showMobileSidebar }">
<h1 style="margin: 0; font-size: 24px">{{ kb.name }}</h1> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
<div style="display: flex; gap: 12px"> <h3 style="margin: 0; font-size: 16px">📁 目录</h3>
<el-button @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button> <el-button size="small" type="primary" @click="openAddCategory(null)">+ 新建</el-button>
<el-button type="warning" @click="handleRegenerateLink" size="large">🔄 重新生成链接</el-button>
</div> </div>
</div>
<!-- AI 链接显示 --> <div
<el-card style="margin-bottom: 20px" shadow="hover"> class="cat-item"
<div style="display: flex; align-items: center; gap: 12px"> :class="{ active: !selectedCategoryId }"
<span style="font-weight: bold; font-size: 14px; white-space: nowrap">AI 访问链接</span> @click="clearCategoryFilter"
<el-input :model-value="aiUrl" readonly style="flex: 1" size="large"> >
<template #append> 📋 全部文档
<el-button @click="handleCopyLink">复制</el-button>
</template>
</el-input>
</div> </div>
</el-card>
<el-card shadow="hover"> <el-tree :data="categories" node-key="id" default-expand-all :expand-on-click-node="false">
<template #header> <template #default="{ data }">
<div style="display: flex; justify-content: space-between; align-items: center"> <div class="tree-node">
<span style="font-size: 16px; font-weight: bold"> <span class="tree-label" :class="{ selected: selectedCategoryId === data.id }" @click="selectCategory(data)">
📄 文档列表 {{ data.is_folder ? '📁' : '📄' }} {{ data.name }}
<el-tag v-if="selectedCategoryPath" closable @close="clearCategoryFilter" style="margin-left: 8px" size="large"> <span v-if="data.doc_count > 0" style="color: #999; font-size: 12px">({{ data.doc_count }})</span>
{{ selectedCategoryPath }} </span>
</el-tag> <span class="tree-actions">
</span> <el-button size="small" text @click.stop="openAddCategory(data.id)">+</el-button>
<div style="display: flex; gap: 10px"> <el-button size="small" text @click.stop="openEditCategory(data)"></el-button>
<el-button size="large" @click="showTextDialog = true"> 添加文本</el-button> <el-button size="small" text type="danger" @click.stop="handleDeleteCategory(data)">×</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf"> </span>
<el-button type="primary" :loading="uploading" size="large">📤 上传文档</el-button> </div>
</el-upload> </template>
</el-tree>
</aside>
<!-- 手机端遮罩 -->
<div v-if="showMobileSidebar" class="mobile-overlay" @click="showMobileSidebar = false"></div>
<!-- 右侧文档列表 -->
<main class="content">
<!-- 桌面端标题 -->
<div class="desktop-header">
<h1 style="margin: 0; font-size: 24px">{{ kb.name }}</h1>
<div style="display: flex; gap: 12px">
<el-button @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
<el-button type="warning" @click="handleRegenerateLink" size="large">🔄 重新生成链接</el-button>
</div>
</div>
<!-- AI 链接 -->
<el-card style="margin-bottom: 16px" shadow="hover">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
<span style="font-weight: bold; font-size: 14px">AI 链接</span>
<el-input :model-value="aiUrl" readonly style="flex: 1; min-width: 200px" size="large">
<template #append>
<el-button @click="handleCopyLink">复制</el-button>
</template>
</el-input>
</div>
</el-card>
<!-- 手机端目录按钮 + 操作按钮 -->
<div class="mobile-actions">
<el-button @click="showMobileSidebar = true" style="flex: 1">📁 目录</el-button>
<el-button @click="showTextDialog = true" style="flex: 1"> 添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf" style="flex: 1">
<el-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button>
</el-upload>
</div>
<el-card shadow="hover">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px">
<span style="font-size: 16px; font-weight: bold">
📄 文档列表
<el-tag v-if="selectedCategoryPath" closable @close="clearCategoryFilter" style="margin-left: 8px">
{{ selectedCategoryPath }}
</el-tag>
</span>
<div class="desktop-actions">
<el-button @click="showTextDialog = true"> 添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
<el-button type="primary" :loading="uploading">📤 上传文档</el-button>
</el-upload>
</div>
</div>
</template>
<!-- 桌面端表格 -->
<div class="desktop-table">
<el-table :data="docs" v-loading="loading" style="width: 100%">
<el-table-column prop="original_filename" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column label="目录" width="180">
<template #default="{ row }">
<el-select :model-value="row.category_id" @change="(val: string) => handleChangeDocCategory(row, val)" placeholder="选择目录" size="small" style="width: 100%">
<el-option v-for="cat in allCategoriesFlat" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="大小" width="80" align="center">
<template #default="{ row }">{{ formatSize(row.file_size) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center">
<template #default="{ row }">
<el-button size="small" @click="handleReprocess(row)">重新解析</el-button>
<el-button size="small" type="danger" @click="handleDeleteDoc(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 手机端卡片列表 -->
<div class="mobile-cards">
<div v-for="doc in docs" :key="doc.id" class="doc-card">
<div style="font-weight: bold; font-size: 15px; margin-bottom: 8px">{{ doc.original_filename }}</div>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap">
<el-tag :type="statusType(doc.status)" size="small">{{ doc.status }}</el-tag>
<span style="color: #999; font-size: 12px">{{ formatSize(doc.file_size) }}</span>
</div>
<el-select :model-value="doc.category_id" @change="(val: string) => handleChangeDocCategory(doc, val)" placeholder="选择目录" size="small" style="width: 100%; margin-bottom: 8px">
<el-option v-for="cat in allCategoriesFlat" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
<div style="display: flex; gap: 8px">
<el-button size="small" style="flex: 1" @click="handleReprocess(doc)">重新解析</el-button>
<el-button size="small" type="danger" style="flex: 1" @click="handleDeleteDoc(doc)">删除</el-button>
</div>
</div> </div>
</div> </div>
</template> </el-card>
</main>
<el-table :data="docs" v-loading="loading" style="width: 100%" size="large">
<el-table-column prop="original_filename" label="标题/文件名" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span style="font-size: 14px; font-weight: 500">{{ row.original_filename }}</span>
</template>
</el-table-column>
<el-table-column label="所属目录" width="200">
<template #default="{ row }">
<el-select
:model-value="row.category_id"
@change="(val: string) => handleChangeDocCategory(row, val)"
placeholder="选择目录"
size="default"
style="width: 100%"
>
<el-option
v-for="cat in allCategoriesFlat"
:key="cat.id"
:label="cat.name"
:value="cat.id"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="default">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<span style="font-size: 13px">{{ row.file_ext }}</span>
</template>
</el-table-column>
<el-table-column label="大小" width="100" align="center">
<template #default="{ row }">
<span style="font-size: 13px">{{ formatSize(row.file_size) }}</span>
</template>
</el-table-column>
<el-table-column prop="title" label="解析标题" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span style="font-size: 13px; color: #666">{{ row.title || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button size="default" @click="handleReprocess(row)">🔄 重新解析</el-button>
<el-button size="default" type="danger" @click="handleDeleteDoc(row)">🗑 删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div> </div>
<!-- 文本内容对话框 --> <!-- 对话框 -->
<el-dialog v-model="showTextDialog" title="✏️ 添加文本内容" width="700px"> <el-dialog v-model="showTextDialog" title="✏️ 添加文本内容" :width="isMobile ? '95%' : '700px'">
<el-form :model="textForm" label-position="top"> <el-form :model="textForm" label-position="top">
<el-form-item label="标题" required> <el-form-item label="标题" required>
<el-input v-model="textForm.title" placeholder="文档标题" size="large" /> <el-input v-model="textForm.title" placeholder="文档标题" size="large" />
</el-form-item> </el-form-item>
<el-form-item label="所属目录"> <el-form-item label="所属目录">
<el-select v-model="textForm.category_id" placeholder="选择目录(可选)" clearable style="width: 100%" size="large"> <el-select v-model="textForm.category_id" placeholder="选择目录" clearable style="width: 100%" size="large">
<el-option <el-option v-for="cat in allCategoriesFlat" :key="cat.id" :label="cat.name" :value="cat.id" />
v-for="cat in allCategoriesFlat"
:key="cat.id"
:label="cat.name"
:value="cat.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="格式"> <el-form-item label="格式">
@@ -409,13 +418,7 @@ function getCategoryName(catId: string) {
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="内容" required> <el-form-item label="内容" required>
<el-input <el-input v-model="textForm.content" type="textarea" :rows="12" placeholder="输入文本内容(支持 Markdown" size="large" />
v-model="textForm.content"
type="textarea"
:rows="15"
placeholder="输入文本内容(支持 Markdown 格式)"
size="large"
/>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -424,25 +427,191 @@ function getCategoryName(catId: string) {
</template> </template>
</el-dialog> </el-dialog>
<!-- 目录管理对话框 --> <el-dialog v-model="showCatDialog" :title="editingCatId ? '✏️ 编辑目录' : '📁 新建目录'" :width="isMobile ? '95%' : '450px'">
<el-dialog v-model="showCatDialog" :title="editingCatId ? '✏️ 编辑目录' : '📁 新建目录'" width="450px">
<el-form :model="catForm" label-position="top"> <el-form :model="catForm" label-position="top">
<el-form-item label="目录名称" required> <el-form-item label="目录名称" required>
<el-input v-model="catForm.name" placeholder="如:公司基本信息" size="large" /> <el-input v-model="catForm.name" placeholder="如:公司基本信息" size="large" />
</el-form-item> </el-form-item>
<el-form-item label="类型"> <el-form-item label="类型">
<el-radio-group v-model="catForm.is_folder" size="large"> <el-radio-group v-model="catForm.is_folder" size="large">
<el-radio :value="true">📁 文件夹可包含子目录</el-radio> <el-radio :value="true">📁 文件夹</el-radio>
<el-radio :value="false">📄 叶子分类存放文档</el-radio> <el-radio :value="false">📄 叶子分类</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="showCatDialog = false" size="large">取消</el-button> <el-button @click="showCatDialog = false" size="large">取消</el-button>
<el-button type="primary" :loading="catLoading" @click="handleSaveCategory" size="large"> <el-button type="primary" :loading="catLoading" @click="handleSaveCategory" size="large">{{ editingCatId ? '保存' : '创建' }}</el-button>
{{ editingCatId ? '保存' : '创建' }}
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<style scoped>
.main-layout {
display: flex;
gap: 20px;
}
.sidebar {
width: 280px;
flex-shrink: 0;
overflow-y: auto;
max-height: calc(100vh - 180px);
position: sticky;
top: 20px;
}
.content {
flex: 1;
min-width: 0;
}
.cat-item {
padding: 10px 12px;
cursor: pointer;
border-radius: 6px;
margin-bottom: 6px;
font-size: 14px;
background: #f5f7fa;
transition: all 0.2s;
}
.cat-item:hover {
background: #e8e8e8;
}
.cat-item.active {
background: #ecf5ff;
color: #409eff;
font-weight: bold;
}
.tree-node {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 4px 0;
}
.tree-label {
cursor: pointer;
font-size: 14px;
flex: 1;
}
.tree-label.selected {
color: #409eff;
font-weight: bold;
}
.tree-actions {
display: flex;
gap: 2px;
}
.mobile-header {
display: none;
}
.mobile-actions {
display: none;
}
.mobile-cards {
display: none;
}
.desktop-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.desktop-actions {
display: flex;
gap: 10px;
}
.mobile-overlay {
display: none;
}
/* 手机端适配 */
@media (max-width: 768px) {
.main-layout {
flex-direction: column;
}
.sidebar {
display: none;
position: fixed;
top: 0;
left: 0;
width: 80%;
max-width: 300px;
height: 100vh;
background: #fff;
z-index: 1000;
padding: 20px;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
overflow-y: auto;
max-height: 100vh;
}
.sidebar.mobile-show {
display: block;
}
.mobile-overlay {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.content {
width: 100%;
}
.mobile-header {
display: block;
margin-bottom: 16px;
}
.desktop-header {
display: none;
}
.mobile-actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.desktop-actions {
display: none;
}
.desktop-table {
display: none;
}
.mobile-cards {
display: block;
}
.doc-card {
border: 1px solid #eee;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
}
</style>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
}, },
}, },
server: { server: {
host: '0.0.0.0',
port: 5173, port: 5173,
proxy: { proxy: {
// 管理端 API // 管理端 API