7
This commit is contained in:
+526
-207
@@ -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:
|
||||||
|
title = doc.title or doc.original_filename
|
||||||
|
lines.append(f"### {title}")
|
||||||
|
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:
|
else:
|
||||||
html += f'{indent}<li{active_class}><a href="/k/{token}?category={node["path"]}">{node["name"]}</a>{doc_count}</li>\n'
|
lines.append("暂无文档。")
|
||||||
return html
|
|
||||||
|
|
||||||
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> > {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>This page is an AI-readable knowledge base index. Use the document links above to retrieve specific documents.</p>
|
<p><a href="/k/{token}">← 返回目录索引</a></p>
|
||||||
<p>本页为 AI 可读知识库目录,请通过上述文档链接获取具体内容。</p>
|
<p>This is an AI-readable document list. Click document links to view content.</p>
|
||||||
</div>
|
<p>本页为 AI 可读文档列表。请点击文档链接查看具体内容。</p>
|
||||||
</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' > <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} > {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("&", "&").replace("<", "<").replace(">", ">")
|
html = markdown.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
# 标题
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
const __VLS_6 = __VLS_asFunctionalComponent(__VLS_5, new __VLS_5({
|
||||||
width: (__VLS_ctx.isCollapsed ? '64px' : '220px'),
|
width: (__VLS_ctx.isCollapsed ? '64px' : '220px'),
|
||||||
...{ style: {} },
|
...{ style: {} },
|
||||||
}));
|
}));
|
||||||
const __VLS_7 = __VLS_6({
|
const __VLS_7 = __VLS_6({
|
||||||
width: (__VLS_ctx.isCollapsed ? '64px' : '220px'),
|
width: (__VLS_ctx.isCollapsed ? '64px' : '220px'),
|
||||||
...{ style: {} },
|
...{ style: {} },
|
||||||
}, ...__VLS_functionalComponentArgsRest(__VLS_6));
|
}, ...__VLS_functionalComponentArgsRest(__VLS_6));
|
||||||
__VLS_8.slots.default;
|
__VLS_8.slots.default;
|
||||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
__VLS_asFunctionalElement(__VLS_intrinsicElements.div, __VLS_intrinsicElements.div)({
|
||||||
...{ style: {} },
|
...{ style: {} },
|
||||||
});
|
});
|
||||||
if (!__VLS_ctx.isCollapsed) {
|
if (!__VLS_ctx.isCollapsed) {
|
||||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({});
|
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({});
|
__VLS_asFunctionalElement(__VLS_intrinsicElements.span, __VLS_intrinsicElements.span)({});
|
||||||
}
|
}
|
||||||
const __VLS_9 = {}.ElMenu;
|
const __VLS_9 = {}.ElMenu;
|
||||||
/** @type {[typeof __VLS_components.ElMenu, typeof __VLS_components.elMenu, typeof __VLS_components.ElMenu, typeof __VLS_components.elMenu, ]} */ ;
|
/** @type {[typeof __VLS_components.ElMenu, typeof __VLS_components.elMenu, typeof __VLS_components.ElMenu, typeof __VLS_components.elMenu, ]} */ ;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const __VLS_10 = __VLS_asFunctionalComponent(__VLS_9, new __VLS_9({
|
const __VLS_10 = __VLS_asFunctionalComponent(__VLS_9, new __VLS_9({
|
||||||
defaultActive: (__VLS_ctx.router.currentRoute.value.path),
|
defaultActive: (__VLS_ctx.router.currentRoute.value.path),
|
||||||
collapse: (__VLS_ctx.isCollapsed),
|
collapse: (__VLS_ctx.isCollapsed),
|
||||||
router: true,
|
router: true,
|
||||||
}));
|
}));
|
||||||
const __VLS_11 = __VLS_10({
|
const __VLS_11 = __VLS_10({
|
||||||
defaultActive: (__VLS_ctx.router.currentRoute.value.path),
|
defaultActive: (__VLS_ctx.router.currentRoute.value.path),
|
||||||
collapse: (__VLS_ctx.isCollapsed),
|
collapse: (__VLS_ctx.isCollapsed),
|
||||||
router: true,
|
router: true,
|
||||||
}, ...__VLS_functionalComponentArgsRest(__VLS_10));
|
}, ...__VLS_functionalComponentArgsRest(__VLS_10));
|
||||||
__VLS_12.slots.default;
|
__VLS_12.slots.default;
|
||||||
const __VLS_13 = {}.ElMenuItem;
|
const __VLS_13 = {}.ElMenuItem;
|
||||||
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
|
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const __VLS_14 = __VLS_asFunctionalComponent(__VLS_13, new __VLS_13({
|
const __VLS_14 = __VLS_asFunctionalComponent(__VLS_13, new __VLS_13({
|
||||||
index: "/",
|
index: "/",
|
||||||
}));
|
}));
|
||||||
const __VLS_15 = __VLS_14({
|
const __VLS_15 = __VLS_14({
|
||||||
index: "/",
|
index: "/",
|
||||||
}, ...__VLS_functionalComponentArgsRest(__VLS_14));
|
}, ...__VLS_functionalComponentArgsRest(__VLS_14));
|
||||||
__VLS_16.slots.default;
|
__VLS_16.slots.default;
|
||||||
const __VLS_17 = {}.ElIcon;
|
const __VLS_17 = {}.ElIcon;
|
||||||
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
|
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const __VLS_18 = __VLS_asFunctionalComponent(__VLS_17, new __VLS_17({}));
|
const __VLS_18 = __VLS_asFunctionalComponent(__VLS_17, new __VLS_17({}));
|
||||||
const __VLS_19 = __VLS_18({}, ...__VLS_functionalComponentArgsRest(__VLS_18));
|
const __VLS_19 = __VLS_18({}, ...__VLS_functionalComponentArgsRest(__VLS_18));
|
||||||
__VLS_20.slots.default;
|
__VLS_20.slots.default;
|
||||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
|
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
|
||||||
viewBox: "0 0 24 24",
|
viewBox: "0 0 24 24",
|
||||||
fill: "currentColor",
|
fill: "currentColor",
|
||||||
});
|
});
|
||||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
|
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
|
||||||
d: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z",
|
d: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z",
|
||||||
});
|
});
|
||||||
var __VLS_20;
|
var __VLS_20;
|
||||||
{
|
{
|
||||||
const { title: __VLS_thisSlot } = __VLS_16.slots;
|
const { title: __VLS_thisSlot } = __VLS_16.slots;
|
||||||
}
|
}
|
||||||
var __VLS_16;
|
var __VLS_16;
|
||||||
const __VLS_21 = {}.ElMenuItem;
|
const __VLS_21 = {}.ElMenuItem;
|
||||||
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
|
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const __VLS_22 = __VLS_asFunctionalComponent(__VLS_21, new __VLS_21({
|
const __VLS_22 = __VLS_asFunctionalComponent(__VLS_21, new __VLS_21({
|
||||||
index: "/knowledge-bases",
|
index: "/knowledge-bases",
|
||||||
}));
|
}));
|
||||||
const __VLS_23 = __VLS_22({
|
const __VLS_23 = __VLS_22({
|
||||||
index: "/knowledge-bases",
|
index: "/knowledge-bases",
|
||||||
}, ...__VLS_functionalComponentArgsRest(__VLS_22));
|
}, ...__VLS_functionalComponentArgsRest(__VLS_22));
|
||||||
__VLS_24.slots.default;
|
__VLS_24.slots.default;
|
||||||
const __VLS_25 = {}.ElIcon;
|
const __VLS_25 = {}.ElIcon;
|
||||||
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
|
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const __VLS_26 = __VLS_asFunctionalComponent(__VLS_25, new __VLS_25({}));
|
const __VLS_26 = __VLS_asFunctionalComponent(__VLS_25, new __VLS_25({}));
|
||||||
const __VLS_27 = __VLS_26({}, ...__VLS_functionalComponentArgsRest(__VLS_26));
|
const __VLS_27 = __VLS_26({}, ...__VLS_functionalComponentArgsRest(__VLS_26));
|
||||||
__VLS_28.slots.default;
|
__VLS_28.slots.default;
|
||||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
|
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
|
||||||
viewBox: "0 0 24 24",
|
viewBox: "0 0 24 24",
|
||||||
fill: "currentColor",
|
fill: "currentColor",
|
||||||
});
|
});
|
||||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
|
__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",
|
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;
|
var __VLS_28;
|
||||||
{
|
{
|
||||||
const { title: __VLS_thisSlot } = __VLS_24.slots;
|
const { title: __VLS_thisSlot } = __VLS_24.slots;
|
||||||
}
|
}
|
||||||
var __VLS_24;
|
var __VLS_24;
|
||||||
const __VLS_29 = {}.ElMenuItem;
|
const __VLS_29 = {}.ElMenuItem;
|
||||||
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
|
/** @type {[typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, typeof __VLS_components.ElMenuItem, typeof __VLS_components.elMenuItem, ]} */ ;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const __VLS_30 = __VLS_asFunctionalComponent(__VLS_29, new __VLS_29({
|
const __VLS_30 = __VLS_asFunctionalComponent(__VLS_29, new __VLS_29({
|
||||||
index: "/settings",
|
index: "/settings",
|
||||||
}));
|
}));
|
||||||
const __VLS_31 = __VLS_30({
|
const __VLS_31 = __VLS_30({
|
||||||
index: "/settings",
|
index: "/settings",
|
||||||
}, ...__VLS_functionalComponentArgsRest(__VLS_30));
|
}, ...__VLS_functionalComponentArgsRest(__VLS_30));
|
||||||
__VLS_32.slots.default;
|
__VLS_32.slots.default;
|
||||||
const __VLS_33 = {}.ElIcon;
|
const __VLS_33 = {}.ElIcon;
|
||||||
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
|
/** @type {[typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, typeof __VLS_components.ElIcon, typeof __VLS_components.elIcon, ]} */ ;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const __VLS_34 = __VLS_asFunctionalComponent(__VLS_33, new __VLS_33({}));
|
const __VLS_34 = __VLS_asFunctionalComponent(__VLS_33, new __VLS_33({}));
|
||||||
const __VLS_35 = __VLS_34({}, ...__VLS_functionalComponentArgsRest(__VLS_34));
|
const __VLS_35 = __VLS_34({}, ...__VLS_functionalComponentArgsRest(__VLS_34));
|
||||||
__VLS_36.slots.default;
|
__VLS_36.slots.default;
|
||||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
|
__VLS_asFunctionalElement(__VLS_intrinsicElements.svg, __VLS_intrinsicElements.svg)({
|
||||||
viewBox: "0 0 24 24",
|
viewBox: "0 0 24 24",
|
||||||
fill: "currentColor",
|
fill: "currentColor",
|
||||||
});
|
});
|
||||||
__VLS_asFunctionalElement(__VLS_intrinsicElements.path)({
|
__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",
|
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;
|
var __VLS_36;
|
||||||
{
|
{
|
||||||
const { title: __VLS_thisSlot } = __VLS_32.slots;
|
const { title: __VLS_thisSlot } = __VLS_32.slots;
|
||||||
|
}
|
||||||
|
var __VLS_32;
|
||||||
|
var __VLS_12;
|
||||||
|
var __VLS_8;
|
||||||
}
|
}
|
||||||
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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+272
-103
@@ -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,54 +255,56 @@ 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">
|
||||||
|
<h1 style="margin: 0; font-size: 20px">{{ kb.name }}</h1>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 10px">
|
||||||
|
<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 class="main-layout">
|
||||||
|
<!-- 左侧:目录树(桌面端常驻,手机端抽屉) -->
|
||||||
|
<aside class="sidebar" :class="{ 'mobile-show': showMobileSidebar }">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
|
||||||
<h3 style="margin: 0; font-size: 16px">📁 目录结构</h3>
|
<h3 style="margin: 0; font-size: 16px">📁 目录</h3>
|
||||||
<el-button size="small" type="primary" @click="openAddCategory(null)">+ 新建</el-button>
|
<el-button size="small" type="primary" @click="openAddCategory(null)">+ 新建</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 全部文档 -->
|
|
||||||
<div
|
<div
|
||||||
style="padding: 10px 12px; cursor: pointer; border-radius: 6px; margin-bottom: 8px; font-size: 14px; transition: all 0.2s"
|
class="cat-item"
|
||||||
:style="{ background: !selectedCategoryId ? '#ecf5ff' : '#f5f7fa', color: !selectedCategoryId ? '#409eff' : '#333', fontWeight: !selectedCategoryId ? 'bold' : 'normal' }"
|
:class="{ active: !selectedCategoryId }"
|
||||||
@click="clearCategoryFilter"
|
@click="clearCategoryFilter"
|
||||||
>
|
>
|
||||||
📋 全部文档
|
📋 全部文档
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 目录树 -->
|
<el-tree :data="categories" node-key="id" default-expand-all :expand-on-click-node="false">
|
||||||
<el-tree
|
<template #default="{ data }">
|
||||||
:data="categories"
|
<div class="tree-node">
|
||||||
node-key="id"
|
<span class="tree-label" :class="{ selected: selectedCategoryId === data.id }" @click="selectCategory(data)">
|
||||||
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 }}
|
{{ 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 v-if="data.doc_count > 0" style="color: #999; font-size: 12px">({{ data.doc_count }})</span>
|
||||||
</span>
|
</span>
|
||||||
<span style="display: flex; gap: 2px">
|
<span class="tree-actions">
|
||||||
<el-button size="small" text @click.stop="openAddCategory(data.id)" style="font-size: 12px">+</el-button>
|
<el-button size="small" text @click.stop="openAddCategory(data.id)">+</el-button>
|
||||||
<el-button size="small" text @click.stop="openEditCategory(data)" style="font-size: 12px">✎</el-button>
|
<el-button size="small" text @click.stop="openEditCategory(data)">✎</el-button>
|
||||||
<el-button size="small" text type="danger" @click.stop="handleDeleteCategory(data)" style="font-size: 12px">×</el-button>
|
<el-button size="small" text type="danger" @click.stop="handleDeleteCategory(data)">×</el-button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-tree>
|
</el-tree>
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
|
<!-- 手机端遮罩 -->
|
||||||
|
<div v-if="showMobileSidebar" class="mobile-overlay" @click="showMobileSidebar = false"></div>
|
||||||
|
|
||||||
<!-- 右侧:文档列表 -->
|
<!-- 右侧:文档列表 -->
|
||||||
<div style="flex: 1; overflow-y: auto">
|
<main class="content">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
|
<!-- 桌面端标题 -->
|
||||||
|
<div class="desktop-header">
|
||||||
<h1 style="margin: 0; font-size: 24px">{{ kb.name }}</h1>
|
<h1 style="margin: 0; font-size: 24px">{{ kb.name }}</h1>
|
||||||
<div style="display: flex; gap: 12px">
|
<div style="display: flex; gap: 12px">
|
||||||
<el-button @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
|
<el-button @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
|
||||||
@@ -302,11 +312,11 @@ function getCategoryName(catId: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI 链接显示 -->
|
<!-- AI 链接 -->
|
||||||
<el-card style="margin-bottom: 20px" shadow="hover">
|
<el-card style="margin-bottom: 16px" shadow="hover">
|
||||||
<div style="display: flex; align-items: center; gap: 12px">
|
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
|
||||||
<span style="font-weight: bold; font-size: 14px; white-space: nowrap">AI 访问链接:</span>
|
<span style="font-weight: bold; font-size: 14px">AI 链接:</span>
|
||||||
<el-input :model-value="aiUrl" readonly style="flex: 1" size="large">
|
<el-input :model-value="aiUrl" readonly style="flex: 1; min-width: 200px" size="large">
|
||||||
<template #append>
|
<template #append>
|
||||||
<el-button @click="handleCopyLink">复制</el-button>
|
<el-button @click="handleCopyLink">复制</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -314,92 +324,91 @@ function getCategoryName(catId: string) {
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</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">
|
<el-card shadow="hover">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center">
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px">
|
||||||
<span style="font-size: 16px; font-weight: bold">
|
<span style="font-size: 16px; font-weight: bold">
|
||||||
📄 文档列表
|
📄 文档列表
|
||||||
<el-tag v-if="selectedCategoryPath" closable @close="clearCategoryFilter" style="margin-left: 8px" size="large">
|
<el-tag v-if="selectedCategoryPath" closable @close="clearCategoryFilter" style="margin-left: 8px">
|
||||||
{{ selectedCategoryPath }}
|
{{ selectedCategoryPath }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</span>
|
</span>
|
||||||
<div style="display: flex; gap: 10px">
|
<div class="desktop-actions">
|
||||||
<el-button size="large" @click="showTextDialog = true">✏️ 添加文本</el-button>
|
<el-button @click="showTextDialog = true">✏️ 添加文本</el-button>
|
||||||
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
|
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
|
||||||
<el-button type="primary" :loading="uploading" size="large">📤 上传文档</el-button>
|
<el-button type="primary" :loading="uploading">📤 上传文档</el-button>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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>
|
<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 }">
|
<template #default="{ row }">
|
||||||
<span style="font-size: 14px; font-weight: 500">{{ row.original_filename }}</span>
|
<el-select :model-value="row.category_id" @change="(val: string) => handleChangeDocCategory(row, val)" placeholder="选择目录" size="small" style="width: 100%">
|
||||||
</template>
|
<el-option v-for="cat in allCategoriesFlat" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||||
</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>
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="100" align="center">
|
<el-table-column label="状态" width="90" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusType(row.status)" size="default">{{ row.status }}</el-tag>
|
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="类型" width="80" align="center">
|
<el-table-column label="大小" width="80" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">{{ formatSize(row.file_size) }}</template>
|
||||||
<span style="font-size: 13px">{{ row.file_ext }}</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="大小" width="100" align="center">
|
<el-table-column label="操作" width="180" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span style="font-size: 13px">{{ formatSize(row.file_size) }}</span>
|
<el-button size="small" @click="handleReprocess(row)">重新解析</el-button>
|
||||||
</template>
|
<el-button size="small" type="danger" @click="handleDeleteDoc(row)">删除</el-button>
|
||||||
</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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文本内容对话框 -->
|
<!-- 手机端:卡片列表 -->
|
||||||
<el-dialog v-model="showTextDialog" title="✏️ 添加文本内容" width="700px">
|
<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>
|
||||||
|
</el-card>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 对话框 -->
|
||||||
|
<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>
|
||||||
|
|||||||
+919
-676
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
// 管理端 API
|
// 管理端 API
|
||||||
|
|||||||
Reference in New Issue
Block a user