Initial commit: book-mcp 教辅处理 MCP server + Arco Vue 管理端
后端 (book_mcp_server.py, 335 行, 15 个 MCP 工具):
- create_book / add_document / mark_html_ready / mark_json_ready / mark_failed
- update_book_status / soft_delete_book / retry_failed
- get_book / list_books / get_document / list_documents / get_document_url
- upload_to_rustfs / health
- 走 JSON-RPC + stateless_http + json_response(前端可直接调)
- launchd 守护 / 6 秒拉起(macOS)
- systemd 守护脚本(Linux/codelab)
存储:
- MongoDB (mongo:8.0 容器) - book/book_document/document_file 三表
- RustFS 对象存储(fs.code-lab.cn/scan/)- book/<id>/docs/<doc_id>/{docx,html,json}
- 幂等设计: zip_sha256 唯一 + (book_id+original_path) 唯一
前端 (Vue 3 + TS + Arco Design):
- 列表页 /book/list - 搜索 + 状态过滤 + 进度
- 详情页 /book/:id - 文档折叠 + 文件下载
- MCP 客户端 src/api/mcp.ts (JSON-RPC over fetch)
部署:
- deploy/install.sh (codelab 一键装 backend + systemd)
- deploy/install-frontend.sh (vite dev / 生产构建)
- docs/deploy.md (运维手册)
测试:
- tests/test_basic.py (sanity 7 项)
- tests/test_e2e.py (完整 e2e: docx→HTML→入库→上传→校验)
This commit is contained in:
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.log
|
||||
|
||||
# Env / secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE / OS
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Tests / artifacts
|
||||
.pytest_cache/
|
||||
coverage/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
target/
|
||||
|
||||
# frontend 备份(用 sed 改过的原文件)
|
||||
package-lock.json.bak
|
||||
340
book_mcp_server.py
Normal file
340
book_mcp_server.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""book-mcp — 教辅处理 MCP 服务(v1 最小可用,MongoDB 后端)
|
||||
|
||||
启动:/tmp/wordparse_venv/bin/python book_mcp_server.py
|
||||
工具数:8 + 1(health)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
# 让 services / db 包能 import
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import yaml
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from db.database import load_config
|
||||
from db import get_collection, COL_DOC, health as db_health
|
||||
from services import (
|
||||
book_service, document_service, file_service,
|
||||
rustfs_service, wordparse,
|
||||
)
|
||||
|
||||
# ---- 日志 ----
|
||||
_cfg = load_config()
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
|
||||
log = logging.getLogger('book-mcp')
|
||||
|
||||
# ---- MCP server ----
|
||||
mcp = FastMCP('book-mcp')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 1. create_book — 创建教辅
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def create_book(name: str, zip_path: str, zip_sha256: str, zip_size: int = 0,
|
||||
subject: str | None = None, grade: str | None = None,
|
||||
version: str | None = None, type: str | None = None,
|
||||
created_by: str | None = None) -> dict:
|
||||
"""创建教辅(按 zip_sha256 幂等)。
|
||||
|
||||
Args:
|
||||
name: 教辅名("2027版.曲一线3•2物理.广西版.一轮复习-突破训练")
|
||||
zip_path: 对象存储里的 zip 路径
|
||||
zip_sha256: zip 文件 sha256(去重用)
|
||||
zip_size: 字节数
|
||||
subject/grade/version/type: 学科/年级/版本/类型(可选)
|
||||
created_by: 创建者(Agent 名 / 用户名)
|
||||
Returns: {ok, book_id, status, total_count, idempotent}
|
||||
"""
|
||||
return book_service.create_book(
|
||||
name=name, zip_path=zip_path, zip_sha256=zip_sha256, zip_size=zip_size,
|
||||
subject=subject, grade=grade, version=version, type_=type,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. add_document — 添加文档
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def add_document(book_id: int, original_filename: str, original_path: str,
|
||||
title: str | None = None, doc_type: str | None = None) -> dict:
|
||||
"""向教辅添加一份文档(按 (book_id, original_path) 幂等)。
|
||||
|
||||
Args:
|
||||
book_id: 教辅 ID
|
||||
original_filename: 原始文件名(含扩展名)
|
||||
original_path: zip 内的相对路径
|
||||
title: 文档标题(可选)
|
||||
doc_type: 文档类型(自动从文件名推断)
|
||||
Returns: {ok, doc_id, order_index, status, doc_type, idempotent}
|
||||
"""
|
||||
return document_service.add_document(
|
||||
book_id=book_id, original_filename=original_filename,
|
||||
original_path=original_path, title=title, doc_type=doc_type,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 3. mark_html_ready — 标记 HTML 已上传
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def mark_html_ready(doc_id: str, html_url: str, html_size: int,
|
||||
formulas: int = 0, images: int = 0, chars: int = 0) -> dict:
|
||||
"""标记文档 HTML 已就绪。
|
||||
|
||||
Args:
|
||||
doc_id: 文档 ID
|
||||
html_url: HTML 的对象存储 URL
|
||||
html_size: 字节数
|
||||
formulas/images/chars: 统计信息
|
||||
Returns: {ok, file_id, object_key, url}
|
||||
"""
|
||||
stats = {'formulas': formulas, 'images': images, 'chars': chars}
|
||||
r = document_service.mark_html_ready(
|
||||
doc_id=doc_id, html_url=html_url, html_size=html_size, stats=stats,
|
||||
)
|
||||
if r.get('ok'):
|
||||
d = get_collection(COL_DOC).find_one({'_id': doc_id})
|
||||
if d:
|
||||
book_service.recompute_book_stats(d['book_id'])
|
||||
return r
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 4. mark_json_ready — 标记 JSON 已上传
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def mark_json_ready(doc_id: str, json_url: str, json_size: int,
|
||||
schema_version: str | None = None) -> dict:
|
||||
"""标记文档结构化 JSON 已就绪。
|
||||
|
||||
Args:
|
||||
doc_id: 文档 ID
|
||||
json_url: JSON 的对象存储 URL
|
||||
json_size: 字节数
|
||||
schema_version: JSON schema 版本(可选)
|
||||
Returns: {ok, file_id, object_key, url}
|
||||
"""
|
||||
return document_service.mark_json_ready(
|
||||
doc_id=doc_id, json_url=json_url, json_size=json_size,
|
||||
schema_version=schema_version,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 5. mark_failed — 标记文档处理失败
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def mark_failed(doc_id: str, error_msg: str, stage: str = 'html') -> dict:
|
||||
"""标记文档处理失败。
|
||||
|
||||
Args:
|
||||
doc_id: 文档 ID
|
||||
error_msg: 失败原因
|
||||
stage: 失败阶段(html / json / upload)
|
||||
Returns: {ok}
|
||||
"""
|
||||
r = document_service.mark_failed(doc_id=doc_id, error_msg=error_msg, stage=stage)
|
||||
if r.get('ok'):
|
||||
d = get_collection(COL_DOC).find_one({'_id': doc_id})
|
||||
if d:
|
||||
book_service.recompute_book_stats(d['book_id'])
|
||||
return r
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 6. update_book_status — 更新教辅状态
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def update_book_status(book_id: int, status: str, error_msg: str | None = None) -> dict:
|
||||
"""更新教辅状态。
|
||||
|
||||
Args:
|
||||
book_id: 教辅 ID
|
||||
status: pending/processing/done/failed/partial
|
||||
error_msg: 错误信息(可选)
|
||||
Returns: {ok}
|
||||
"""
|
||||
ok = book_service.update_book_status(book_id, status, error_msg)
|
||||
return {'ok': ok}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 7. get_book — 查教辅(含所有 doc + file)
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def get_book(book_id: int) -> dict:
|
||||
"""查教辅完整信息(教辅 + 所有文档 + 每个文档的所有文件)。
|
||||
|
||||
Args:
|
||||
book_id: 教辅 ID
|
||||
Returns: 完整 book dict(documents 嵌套 files),或 None
|
||||
"""
|
||||
return book_service.get_book(book_id, include_documents=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 8. list_books — 列表查询(分页)
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def list_books(status: str | None = None, page: int = 1, page_size: int = 20,
|
||||
keyword: str | None = None) -> dict:
|
||||
"""列出教辅(分页)。
|
||||
|
||||
Args:
|
||||
status: 状态过滤(pending/processing/done/failed/partial)
|
||||
page: 页码(从 1 开始)
|
||||
page_size: 每页条数
|
||||
keyword: 关键词(name/subject/grade/version 模糊匹配)
|
||||
Returns: {books[], total, page, page_size}
|
||||
"""
|
||||
return book_service.list_books(status=status, page=page,
|
||||
page_size=page_size, keyword=keyword)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 9. get_document — 查单文档(含所有 file)
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def get_document(doc_id: str) -> dict:
|
||||
"""查单文档(所有 file 一起返)。
|
||||
|
||||
Args:
|
||||
doc_id: 文档 ID(16 字符 sha1)
|
||||
Returns: 完整 document dict(files[]),或 None
|
||||
"""
|
||||
return document_service.get_document(doc_id, include_files=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 10. list_documents — 文档列表(按 book_id 分页)
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def list_documents(book_id: int, status: str | None = None,
|
||||
page: int = 1, page_size: int = 50) -> dict:
|
||||
"""列出教辅的所有文档(按 order_index 排序,分页)。
|
||||
|
||||
Args:
|
||||
book_id: 教辅 ID
|
||||
status: 状态过滤(pending/html_ready/json_ready/failed)
|
||||
page: 页码(从 1 开始)
|
||||
page_size: 每页条数
|
||||
Returns: {documents[], total, page, page_size}
|
||||
"""
|
||||
return document_service.list_documents(book_id, status=status,
|
||||
page=page, page_size=page_size)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 11. get_document_url — 查文档某个 file 的 URL
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def get_document_url(doc_id: str, file_type: str) -> dict:
|
||||
"""查文档某个 file(html/json/docx/error_log)的 URL。
|
||||
|
||||
Args:
|
||||
doc_id: 文档 ID
|
||||
file_type: html / json / docx / error_log
|
||||
Returns: {file_type, url, size, checksum} 或 None
|
||||
"""
|
||||
f = file_service.get_file_by_type(doc_id, file_type)
|
||||
if not f:
|
||||
return None
|
||||
return {
|
||||
'file_type': f['file_type'],
|
||||
'url': f['url'],
|
||||
'size': f['size'],
|
||||
'checksum': f['checksum'],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 12. upload_to_rustfs — 直接上传本地文件到对象存储
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def upload_to_rustfs(local_path: str, key: str,
|
||||
bucket: str | None = None) -> dict:
|
||||
"""上传本地文件到 RustFS(Agent 用的上传助手)。
|
||||
|
||||
Args:
|
||||
local_path: 本地文件路径
|
||||
key: 对象 key(如 'book/123/docs/abc/output.html')
|
||||
bucket: 桶名(默认 config.yaml 的 default_bucket)
|
||||
Returns: {url, key, bucket, size, checksum}
|
||||
"""
|
||||
if not os.path.exists(local_path):
|
||||
return {'ok': False, 'error': f'file not found: {local_path}'}
|
||||
return rustfs_service.upload(local_path, key, bucket=bucket)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 13. soft_delete_book — 软删教辅
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def soft_delete_book(book_id: int) -> dict:
|
||||
"""软删教辅(标记 is_deleted=1,不删对象存储)。
|
||||
|
||||
Args:
|
||||
book_id: 教辅 ID
|
||||
Returns: {ok}
|
||||
"""
|
||||
ok = book_service.soft_delete_book(book_id)
|
||||
return {'ok': ok}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 14. retry_failed — 重试失败的文档
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def retry_failed(book_id: int) -> dict:
|
||||
"""把教辅下所有 failed 文档改回 pending,方便重跑。
|
||||
|
||||
Args:
|
||||
book_id: 教辅 ID
|
||||
Returns: {ok, retried_count}
|
||||
"""
|
||||
from db import get_collection, COL_DOC
|
||||
col = get_collection(COL_DOC)
|
||||
r = col.update_many(
|
||||
{'book_id': book_id, 'status': 'failed'},
|
||||
{'$set': {'status': 'pending', 'error_msg': None}}
|
||||
)
|
||||
book_service.recompute_book_stats(book_id)
|
||||
return {'ok': True, 'retried_count': r.modified_count}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# health — 健康检查
|
||||
# ============================================================
|
||||
@mcp.tool()
|
||||
def health() -> dict:
|
||||
"""健康检查:wordparse 服务 + mongo"""
|
||||
return {
|
||||
'wordparse': wordparse.health(),
|
||||
'mongo': db_health(),
|
||||
}
|
||||
|
||||
|
||||
# ---- main ----
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--port', type=int, default=8100)
|
||||
parser.add_argument('--host', default='127.0.0.1')
|
||||
parser.add_argument('--init-db', action='store_true', help='初始化 mongo 索引')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.init_db:
|
||||
from db import init_db
|
||||
init_db()
|
||||
sys.exit(0)
|
||||
|
||||
log.info(f"book-mcp 启动:http://{args.host}:{args.port}")
|
||||
log.info(f"wordparse 服务: {_cfg['wordparse']['url']}")
|
||||
# stateless_http + json_response:让前端跨域 JSON-RPC 调用不需要 SSE
|
||||
mcp.run(transport='http', host=args.host, port=args.port,
|
||||
stateless_http=True, json_response=True)
|
||||
43
config.yaml
Normal file
43
config.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
# book-mcp 服务配置
|
||||
|
||||
server:
|
||||
name: book-mcp
|
||||
port: 8100
|
||||
host: 127.0.0.1
|
||||
|
||||
database:
|
||||
# MongoDB
|
||||
url: mongodb://book_admin:book_mongo_2026@127.0.0.1:27018/book_mcp?authSource=admin
|
||||
db_name: book_mcp
|
||||
echo: false
|
||||
|
||||
rustfs:
|
||||
endpoint: https://fs.code-lab.cn
|
||||
access_key: rustfsadmin
|
||||
secret_key: rustfsadmin_sTwHez
|
||||
default_bucket: scan
|
||||
public_base: https://fs.code-lab.cn
|
||||
verify_ssl: false
|
||||
|
||||
wordparse:
|
||||
url: http://127.0.0.1:8089/api/v3/server_office_file/file/parse
|
||||
health_url: http://127.0.0.1:8089/api/v3/server_office_file/file/health
|
||||
timeout: 600
|
||||
# 自动清洗脏 docx(去单位/NaN/浮点取整)
|
||||
auto_clean: true
|
||||
|
||||
storage:
|
||||
# 对象存储路径模板
|
||||
book_prefix: book
|
||||
docs_subdir: docs
|
||||
# file_type → 子文件名
|
||||
file_naming:
|
||||
docx: source.docx
|
||||
html: output.html
|
||||
json: structure.json
|
||||
preview_png: preview.png
|
||||
error_log: error.log
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
format: '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
|
||||
19
db/__init__.py
Normal file
19
db/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""db 子包 — MongoDB 文档模型 + 连接管理
|
||||
|
||||
子模块:
|
||||
models — Book / BookDocument / DocumentFile helper class
|
||||
database — MongoClient + collection + init_db
|
||||
"""
|
||||
from .models import Book, BookDocument, DocumentFile
|
||||
from .database import (
|
||||
load_config, get_client, get_db, get_collection,
|
||||
init_db, health, next_id,
|
||||
COL_BOOK, COL_DOC, COL_FILE, COL_COUNTER,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Book', 'BookDocument', 'DocumentFile',
|
||||
'load_config', 'get_client', 'get_db', 'get_collection',
|
||||
'init_db', 'health', 'next_id',
|
||||
'COL_BOOK', 'COL_DOC', 'COL_FILE', 'COL_COUNTER',
|
||||
]
|
||||
98
db/database.py
Normal file
98
db/database.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""MongoDB 连接 + collection 管理
|
||||
|
||||
3 个 collection:
|
||||
book — 教辅
|
||||
book_document — 教辅文档
|
||||
document_file — 文档文件
|
||||
|
||||
设计思路:
|
||||
- _id 用业务 ID(doc_id 16 字符 sha1 / book_id 自增字符串 / file_id 自增字符串)
|
||||
- book_id 用 INT 自增(mongo 中用 find_one_and_update + $inc 实现)
|
||||
- 不强制 schema(mongo 灵活),但用 helper 函数做字段约束
|
||||
- 索引在 init_db() 时一次性建好
|
||||
"""
|
||||
import os
|
||||
import yaml
|
||||
from pymongo import MongoClient, ASCENDING, DESCENDING, ReturnDocument
|
||||
from pymongo.database import Database
|
||||
|
||||
_CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.yaml')
|
||||
|
||||
_client: MongoClient | None = None
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
with open(_CONFIG_PATH) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def get_client() -> MongoClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
cfg = load_config()['database']
|
||||
_client = MongoClient(cfg['url'], serverSelectionTimeoutMS=5000)
|
||||
return _client
|
||||
|
||||
|
||||
def get_db() -> Database:
|
||||
cfg = load_config()['database']
|
||||
return get_client()[cfg['db_name']]
|
||||
|
||||
|
||||
def get_collection(name: str):
|
||||
return get_db()[name]
|
||||
|
||||
|
||||
# ---- collection 名常量 ----
|
||||
COL_BOOK = 'book'
|
||||
COL_DOC = 'book_document'
|
||||
COL_FILE = 'document_file'
|
||||
|
||||
# ---- counters collection(book_id / file_id 自增用)----
|
||||
COL_COUNTER = 'counters'
|
||||
|
||||
|
||||
# ---- 索引初始化 ----
|
||||
def init_db():
|
||||
"""建所有索引(幂等)"""
|
||||
db = get_db()
|
||||
|
||||
# book: zip_sha256 唯一 + (name, version, is_deleted) 唯一
|
||||
db[COL_BOOK].create_index([('zip_sha256', ASCENDING)], unique=True, name='uk_book_zip_sha')
|
||||
db[COL_BOOK].create_index([('name', ASCENDING), ('version', ASCENDING), ('is_deleted', ASCENDING)],
|
||||
unique=True, name='uk_book_name_version')
|
||||
db[COL_BOOK].create_index([('status', ASCENDING)], name='idx_book_status')
|
||||
db[COL_BOOK].create_index([('created_at', DESCENDING)], name='idx_book_created')
|
||||
|
||||
# book_document: _id 已唯一(doc_id 即 _id);额外索引仅用于查询加速
|
||||
db[COL_DOC].create_index([('book_id', ASCENDING), ('order_index', ASCENDING)], name='idx_book_order')
|
||||
db[COL_DOC].create_index([('status', ASCENDING)], name='idx_doc_status')
|
||||
|
||||
# document_file: (doc_id, file_type) 唯一 + (bucket, key)
|
||||
db[COL_FILE].create_index([('doc_id', ASCENDING), ('file_type', ASCENDING)],
|
||||
unique=True, name='uk_doc_type')
|
||||
db[COL_FILE].create_index([('object_bucket', ASCENDING), ('object_key', ASCENDING)],
|
||||
name='idx_bucket_key')
|
||||
|
||||
print(f"✓ MongoDB 已就绪(db: {db.name}, collections: {[COL_BOOK, COL_DOC, COL_FILE]})")
|
||||
print(f" ✓ 8 个索引创建完成")
|
||||
|
||||
|
||||
def health() -> bool:
|
||||
try:
|
||||
get_client().admin.command('ping')
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def next_id(name: str) -> int:
|
||||
"""原子自增 ID(mongo 没有 AUTO_INCREMENT,用 counters collection 模拟)"""
|
||||
db = get_db()
|
||||
r = db[COL_COUNTER].find_one_and_update(
|
||||
{'_id': name},
|
||||
{'$inc': {'seq': 1}},
|
||||
upsert=True,
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
return int(r['seq'])
|
||||
226
db/models.py
Normal file
226
db/models.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""MongoDB 文档模型 — helper class(非 ORM,纯字典封装)
|
||||
|
||||
3 个 collection:
|
||||
Book → book
|
||||
BookDocument → book_document
|
||||
DocumentFile → document_file
|
||||
|
||||
每个 class 有 to_mongo / from_mongo / to_dict 等方法。
|
||||
"""
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Book — 教辅
|
||||
# ============================================================
|
||||
class Book:
|
||||
"""教辅文档。_id = book_id(int)"""
|
||||
COLLECTION = 'book'
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.id: Optional[int] = kwargs.get('id')
|
||||
self.name: str = kwargs['name']
|
||||
self.subject: Optional[str] = kwargs.get('subject')
|
||||
self.grade: Optional[str] = kwargs.get('grade')
|
||||
self.version: Optional[str] = kwargs.get('version')
|
||||
self.type: Optional[str] = kwargs.get('type')
|
||||
self.zip_path: str = kwargs['zip_path']
|
||||
self.zip_sha256: str = kwargs['zip_sha256']
|
||||
self.zip_size: int = kwargs.get('zip_size', 0)
|
||||
self.status: str = kwargs.get('status', 'pending')
|
||||
self.total_count: int = kwargs.get('total_count', 0)
|
||||
self.success_count: int = kwargs.get('success_count', 0)
|
||||
self.failed_count: int = kwargs.get('failed_count', 0)
|
||||
self.error_msg: Optional[str] = kwargs.get('error_msg')
|
||||
self.created_at: datetime = kwargs.get('created_at', _now())
|
||||
self.updated_at: datetime = kwargs.get('updated_at', _now())
|
||||
self.created_by: Optional[str] = kwargs.get('created_by')
|
||||
self.is_deleted: int = kwargs.get('is_deleted', 0)
|
||||
|
||||
def to_mongo(self) -> dict:
|
||||
d = {
|
||||
'name': self.name,
|
||||
'subject': self.subject,
|
||||
'grade': self.grade,
|
||||
'version': self.version,
|
||||
'type': self.type,
|
||||
'zip_path': self.zip_path,
|
||||
'zip_sha256': self.zip_sha256,
|
||||
'zip_size': self.zip_size,
|
||||
'status': self.status,
|
||||
'total_count': self.total_count,
|
||||
'success_count': self.success_count,
|
||||
'failed_count': self.failed_count,
|
||||
'error_msg': self.error_msg,
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
'created_by': self.created_by,
|
||||
'is_deleted': self.is_deleted,
|
||||
}
|
||||
if self.id is not None:
|
||||
d['_id'] = self.id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_mongo(cls, doc: dict) -> 'Book':
|
||||
if not doc:
|
||||
return None
|
||||
d = dict(doc)
|
||||
d['id'] = d.pop('_id', None)
|
||||
return cls(**d)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'subject': self.subject,
|
||||
'grade': self.grade,
|
||||
'version': self.version,
|
||||
'type': self.type,
|
||||
'zip_path': self.zip_path,
|
||||
'zip_sha256': self.zip_sha256,
|
||||
'zip_size': self.zip_size,
|
||||
'status': self.status,
|
||||
'total_count': self.total_count,
|
||||
'success_count': self.success_count,
|
||||
'failed_count': self.failed_count,
|
||||
'error_msg': self.error_msg,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'created_by': self.created_by,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BookDocument — 教辅文档
|
||||
# ============================================================
|
||||
class BookDocument:
|
||||
"""教辅文档。_id = doc_id(16 字符 sha1 前缀)"""
|
||||
COLLECTION = 'book_document'
|
||||
|
||||
DOC_TYPES = ('课时作业', '周末提升', '阶段质量评估', '综合评估', 'unknown')
|
||||
STATUSES = ('pending', 'html_ready', 'json_ready', 'failed')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.book_id: int = kwargs['book_id']
|
||||
self.original_filename: str = kwargs['original_filename']
|
||||
self.original_path: str = kwargs['original_path']
|
||||
self.doc_id: str = kwargs.get('doc_id') or self.make_doc_id(
|
||||
kwargs['book_id'], kwargs['original_path'])
|
||||
self.title: Optional[str] = kwargs.get('title')
|
||||
self.doc_type: str = kwargs.get('doc_type', 'unknown')
|
||||
self.order_index: int = kwargs.get('order_index', 0)
|
||||
self.status: str = kwargs.get('status', 'pending')
|
||||
self.error_msg: Optional[str] = kwargs.get('error_msg')
|
||||
self.created_at: datetime = kwargs.get('created_at', _now())
|
||||
self.updated_at: datetime = kwargs.get('updated_at', _now())
|
||||
|
||||
@staticmethod
|
||||
def make_doc_id(book_id: int, original_path: str) -> str:
|
||||
"""稳定 doc_id:sha1(book_id|path).hexdigest()[:16]"""
|
||||
h = hashlib.sha1(f"{book_id}|{original_path}".encode('utf-8')).hexdigest()
|
||||
return h[:16]
|
||||
|
||||
def to_mongo(self) -> dict:
|
||||
return {
|
||||
'_id': self.doc_id,
|
||||
'book_id': self.book_id,
|
||||
'original_filename': self.original_filename,
|
||||
'original_path': self.original_path,
|
||||
'title': self.title,
|
||||
'doc_type': self.doc_type,
|
||||
'order_index': self.order_index,
|
||||
'status': self.status,
|
||||
'error_msg': self.error_msg,
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mongo(cls, doc: dict) -> 'BookDocument':
|
||||
if not doc:
|
||||
return None
|
||||
d = dict(doc)
|
||||
d['doc_id'] = d.pop('_id')
|
||||
return cls(**d)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'book_id': self.book_id,
|
||||
'doc_id': self.doc_id,
|
||||
'original_filename': self.original_filename,
|
||||
'original_path': self.original_path,
|
||||
'title': self.title,
|
||||
'doc_type': self.doc_type,
|
||||
'order_index': self.order_index,
|
||||
'status': self.status,
|
||||
'error_msg': self.error_msg,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DocumentFile — 文档文件
|
||||
# ============================================================
|
||||
class DocumentFile:
|
||||
"""文档文件。_id = 自增 int(next_id('file'))"""
|
||||
COLLECTION = 'document_file'
|
||||
|
||||
FILE_TYPES = ('docx', 'html', 'json', 'preview_png', 'error_log')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.id: Optional[int] = kwargs.get('id')
|
||||
self.doc_id: str = kwargs['doc_id']
|
||||
self.file_type: str = kwargs['file_type']
|
||||
self.object_key: str = kwargs['object_key']
|
||||
self.object_bucket: str = kwargs['object_bucket']
|
||||
self.size: int = kwargs.get('size', 0)
|
||||
self.checksum: Optional[str] = kwargs.get('checksum')
|
||||
self.url: Optional[str] = kwargs.get('url')
|
||||
self.meta: Optional[dict] = kwargs.get('meta')
|
||||
self.created_at: datetime = kwargs.get('created_at', _now())
|
||||
|
||||
def to_mongo(self) -> dict:
|
||||
d = {
|
||||
'doc_id': self.doc_id,
|
||||
'file_type': self.file_type,
|
||||
'object_key': self.object_key,
|
||||
'object_bucket': self.object_bucket,
|
||||
'size': self.size,
|
||||
'checksum': self.checksum,
|
||||
'url': self.url,
|
||||
'meta': self.meta,
|
||||
'created_at': self.created_at,
|
||||
}
|
||||
if self.id is not None:
|
||||
d['_id'] = self.id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_mongo(cls, doc: dict) -> 'DocumentFile':
|
||||
if not doc:
|
||||
return None
|
||||
d = dict(doc)
|
||||
d['id'] = d.pop('_id')
|
||||
return cls(**d)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'id': self.id,
|
||||
'doc_id': self.doc_id,
|
||||
'file_type': self.file_type,
|
||||
'object_key': self.object_key,
|
||||
'object_bucket': self.object_bucket,
|
||||
'size': self.size,
|
||||
'checksum': self.checksum,
|
||||
'url': self.url,
|
||||
'meta': self.meta,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
77
db/schema_doc.md
Normal file
77
db/schema_doc.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# MongoDB Schema — book-mcp
|
||||
|
||||
3 个 collection(无固定 schema,字段约束在 `db/models.py`):
|
||||
|
||||
## book(教辅)
|
||||
|
||||
| 字段 | 类型 | 索引 |
|
||||
|---|---|---|
|
||||
| `_id` | int | PK(自增,counters.book_id)|
|
||||
| `name` | str | uk_name_version 一部分 |
|
||||
| `subject` | str \| null | |
|
||||
| `grade` | str \| null | |
|
||||
| `version` | str \| null | uk_name_version 一部分 |
|
||||
| `type` | str \| null | |
|
||||
| `zip_path` | str | |
|
||||
| `zip_sha256` | str | **uk_book_zip_sha 唯一** |
|
||||
| `zip_size` | int | |
|
||||
| `status` | enum | idx_book_status |
|
||||
| `total_count` | int | |
|
||||
| `success_count` | int | |
|
||||
| `failed_count` | int | |
|
||||
| `error_msg` | str \| null | |
|
||||
| `created_at` | datetime | idx_book_created |
|
||||
| `updated_at` | datetime | |
|
||||
| `created_by` | str \| null | |
|
||||
| `is_deleted` | int (0/1) | uk_name_version 一部分 |
|
||||
|
||||
## book_document(教辅文档)
|
||||
|
||||
| 字段 | 类型 | 索引 |
|
||||
|---|---|---|
|
||||
| `_id` | str (16 字符 sha1) | **uk_doc_id 唯一** |
|
||||
| `book_id` | int | idx_book_order |
|
||||
| `original_filename` | str | |
|
||||
| `original_path` | str | |
|
||||
| `title` | str \| null | |
|
||||
| `doc_type` | enum | |
|
||||
| `order_index` | int | idx_book_order |
|
||||
| `status` | enum | idx_doc_status |
|
||||
| `error_msg` | str \| null | |
|
||||
| `created_at` | datetime | |
|
||||
| `updated_at` | datetime | |
|
||||
|
||||
## document_file(文档文件)
|
||||
|
||||
| 字段 | 类型 | 索引 |
|
||||
|---|---|---|
|
||||
| `_id` | int | PK(自增,counters.file_id)|
|
||||
| `doc_id` | str | **uk_doc_type 联合唯一** + idx_bucket_key |
|
||||
| `file_type` | enum | uk_doc_type 联合唯一 |
|
||||
| `object_key` | str | idx_bucket_key |
|
||||
| `object_bucket` | str | idx_bucket_key |
|
||||
| `size` | int | |
|
||||
| `checksum` | str \| null | |
|
||||
| `url` | str \| null | |
|
||||
| `meta` | object \| null | |
|
||||
| `created_at` | datetime | |
|
||||
|
||||
## counters(自增 ID 模拟)
|
||||
|
||||
| _id (name) | seq |
|
||||
|---|---|
|
||||
| `book_id` | int |
|
||||
| `file_id` | int |
|
||||
|
||||
## 初始化
|
||||
|
||||
```bash
|
||||
# 自动建所有索引(幂等)
|
||||
/tmp/wordparse_venv/bin/python book_mcp_server.py --init-db
|
||||
```
|
||||
|
||||
输出:
|
||||
```
|
||||
✓ MongoDB 已就绪(db: book_mcp, collections: ['book', 'book_document', 'document_file'])
|
||||
✓ 8 个索引创建完成
|
||||
```
|
||||
66
deploy/install-frontend.sh
Executable file
66
deploy/install-frontend.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# book-mcp 前端部署 — codelab 上跑(独立脚本,从 install.sh 调或手动调)
|
||||
#
|
||||
# 两条路径:
|
||||
# A. dev 模式(端口 5176 + 反代 80)
|
||||
# B. 生产构建(dist/ + nginx/caddy)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../frontend"
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
|
||||
log "→ 装前端依赖"
|
||||
[ -d node_modules ] || npm install --no-audit --no-fund
|
||||
log " ✓ 依赖 OK"
|
||||
|
||||
# ============================================================
|
||||
# A. dev 模式:用 tmux/systemd 起 vite
|
||||
# ============================================================
|
||||
if [ "${1:-dev}" = "dev" ]; then
|
||||
log "→ 起 vite dev(端口 5176)"
|
||||
cat > /tmp/book-mcp-frontend.service <<EOF
|
||||
[Unit]
|
||||
Description=book-mcp frontend dev
|
||||
After=book-mcp.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$PWD
|
||||
Environment=NODE_ENV=development
|
||||
ExecStart=$PWD/node_modules/.bin/vite --config ./config/vite.config.dev.ts
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=$USER
|
||||
StandardOutput=append:/var/log/book-mcp/frontend.log
|
||||
StandardError=append:/var/log/book-mcp/frontend.error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
sudo mv /tmp/book-mcp-frontend.service /etc/systemd/system/book-mcp-frontend.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable book-mcp-frontend.service
|
||||
sudo systemctl restart book-mcp-frontend.service
|
||||
sleep 5
|
||||
log " ✓ vite dev 已起(端口 5176)"
|
||||
|
||||
# ============================================================
|
||||
# B. 生产构建
|
||||
# ============================================================
|
||||
else
|
||||
log "→ 生产构建"
|
||||
npm run build
|
||||
log " ✓ dist/ 已生成"
|
||||
log ""
|
||||
log "前端 dist 在 $PWD/dist/,nginx 配置示例:"
|
||||
log " server { listen 80; root $PWD/dist; location /mcp { proxy_pass http://127.0.0.1:8100; } }"
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "============================================================"
|
||||
log "✅ 前端部署完成"
|
||||
log " dev URL: http://127.0.0.1:5176"
|
||||
log " 生产: dist/ 目录"
|
||||
log " 服务管理: sudo systemctl {start|stop|restart|status} book-mcp-frontend"
|
||||
log "============================================================"
|
||||
144
deploy/install.sh
Executable file
144
deploy/install.sh
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# book-mcp 一键部署 — 在 codelab 上跑
|
||||
#
|
||||
# 用法(在 codelab 上):
|
||||
# cd ~/book-mcp
|
||||
# bash deploy/install.sh
|
||||
#
|
||||
# 准备:
|
||||
# 1. docker 已装
|
||||
# 2. python3.11+ 已装
|
||||
# 3. nginx/caddy 二选一(可选,本脚本用 caddy 配反代)
|
||||
#
|
||||
# 部署:
|
||||
# - book-mcp-mongo(27018)
|
||||
# - book-mcp-server(8100,由 launchd 守护)
|
||||
# - frontend(vite dev 5176 or 生产构建)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
ROOT="$(pwd)"
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
|
||||
# ============================================================
|
||||
# 1. MongoDB 容器(已存在则跳过)
|
||||
# ============================================================
|
||||
log "→ 检查 book-mcp-mongo"
|
||||
if ! docker ps -a --format '{{.Names}}' | grep -q '^book-mcp-mongo$'; then
|
||||
log " 启动 mongo 容器..."
|
||||
docker run -d \
|
||||
--name book-mcp-mongo \
|
||||
-p 27018:27017 \
|
||||
-e MONGO_INITDB_ROOT_USERNAME=book_admin \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD=book_mongo_2026 \
|
||||
-e MONGO_INITDB_DATABASE=book_mcp \
|
||||
-v book-mcp-mongo-data:/data/db \
|
||||
--restart unless-stopped \
|
||||
mongo:8.0
|
||||
log " ✓ 容器已起(密码:book_mongo_2026)"
|
||||
sleep 4
|
||||
else
|
||||
log " ✓ 容器已存在"
|
||||
docker start book-mcp-mongo 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 2. Python venv + 依赖
|
||||
# ============================================================
|
||||
log "→ 准备 Python venv"
|
||||
VENV=/opt/book-mcp-venv
|
||||
if [ ! -d "$VENV" ]; then
|
||||
python3 -m venv "$VENV"
|
||||
log " ✓ venv 创建"
|
||||
fi
|
||||
$VENV/bin/pip install -q --upgrade pip
|
||||
$VENV/bin/pip install -q -r "$ROOT/requirements.txt"
|
||||
log " ✓ Python 依赖装好"
|
||||
|
||||
# ============================================================
|
||||
# 3. MongoDB 索引
|
||||
# ============================================================
|
||||
log "→ 建 MongoDB 索引"
|
||||
$VENV/bin/python "$ROOT/book_mcp_server.py" --init-db
|
||||
log " ✓ 索引建好"
|
||||
|
||||
# ============================================================
|
||||
# 4. 配置
|
||||
# ============================================================
|
||||
log "→ 写 .env(如果不存在)"
|
||||
if [ ! -f "$ROOT/.env" ]; then
|
||||
cat > "$ROOT/.env" <<EOF
|
||||
BOOK_MCP_HOME=$ROOT
|
||||
BOOK_MCP_MONGO_URL=mongodb://book_admin:book_mongo_2026@127.0.0.1:27018/book_mcp?authSource=admin
|
||||
EOF
|
||||
log " ✓ .env 写好"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 5. systemd unit(替代 launchd,因为是 Linux)
|
||||
# ============================================================
|
||||
log "→ 装 systemd unit"
|
||||
sudo tee /etc/systemd/system/book-mcp.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=book-mcp MCP server
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
Wants=book-mcp-mongo.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$ROOT
|
||||
Environment=BOOK_MCP_HOME=$ROOT
|
||||
Environment=PATH=$VENV/bin:/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=$VENV/bin/python $ROOT/book_mcp_server.py --port 8100
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=$USER
|
||||
StandardOutput=append:/var/log/book-mcp/server.log
|
||||
StandardError=append:/var/log/book-mcp/server.error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo mkdir -p /var/log/book-mcp
|
||||
sudo chown $USER:$USER /var/log/book-mcp
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable book-mcp.service
|
||||
sudo systemctl restart book-mcp.service
|
||||
sleep 3
|
||||
log " ✓ systemd 服务已起"
|
||||
|
||||
# ============================================================
|
||||
# 6. 健康检查
|
||||
# ============================================================
|
||||
log "→ 健康检查"
|
||||
sleep 2
|
||||
if $VENV/bin/python -c "
|
||||
import asyncio
|
||||
from fastmcp.client import Client
|
||||
async def m():
|
||||
async with Client('http://127.0.0.1:8100/mcp') as c:
|
||||
h = await c.call_tool('health', {})
|
||||
assert h.data['wordparse'] and h.data['mongo']
|
||||
print('OK:', h.data)
|
||||
asyncio.run(m())
|
||||
" 2>&1; then
|
||||
log " ✓ 健康"
|
||||
else
|
||||
log " ✗ 健康检查失败,看 /var/log/book-mcp/server.error.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "============================================================"
|
||||
log "✅ book-mcp 部署完成"
|
||||
log "============================================================"
|
||||
log " MCP endpoint: http://127.0.0.1:8100/mcp"
|
||||
log " MongoDB: mongodb://book_admin:book_mongo_2026@127.0.0.1:27018/book_mcp?authSource=admin"
|
||||
log " 日志: /var/log/book-mcp/server.log"
|
||||
log " 服务管理: sudo systemctl {start|stop|restart|status} book-mcp"
|
||||
log ""
|
||||
log " 部署前端:cd frontend && bash ../deploy/install-frontend.sh"
|
||||
373
docs/book-mcp-design.md
Normal file
373
docs/book-mcp-design.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# 教辅处理 MCP 服务 — 方案设计
|
||||
|
||||
> **作者**: 助手(Quentin 的 AI 助手)
|
||||
> **日期**: 2026-10-11
|
||||
> **状态**: 待评审
|
||||
|
||||
---
|
||||
|
||||
## 1. 业务目标
|
||||
|
||||
> Agent 上传一个教辅 zip → MCP 自动解压 + 批量转 HTML + 上传对象存储 + 入库 → 后续页面可查、可看 HTML。
|
||||
|
||||
### 核心工作流
|
||||
|
||||
```
|
||||
┌─ Agent 端 ───────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 1) Agent: upload_zip(local_zip) → rustfs 返回 key │
|
||||
│ 2) Agent: mcp.create_book(name, zip_path=key) │
|
||||
│ ↓ │
|
||||
│ 返回 {book_id, status="pending"} │
|
||||
│ │
|
||||
│ 3) Agent: 本地解压 zip │
|
||||
│ 4) Agent: 循环每 docx │
|
||||
│ ├─ mcp.add_document(book_id, filename, path) │
|
||||
│ │ ↓ 返回 doc_id │
|
||||
│ ├─ wordparse.docx_to_html(docx) → 本地 html │
|
||||
│ ├─ mcp.upload_to_rustfs(html, book/doc_id/html) │
|
||||
│ ├─ Agent 本地解析 html → structure.json │
|
||||
│ ├─ mcp.upload_to_rustfs(json, book/doc_id/json) │
|
||||
│ ├─ mcp.mark_html_ready(doc_id, html_url, stats) │
|
||||
│ └─ mcp.mark_json_ready(doc_id, json_url, ver) │
|
||||
│ │
|
||||
│ 5) Agent: mcp.update_book_status(book_id, "done") │
|
||||
│ │
|
||||
└─ 后续页面 ───────────────────────────────────────────┘
|
||||
│ - HTTP API 查 book / doc / file(复用 mcp 的查询类) │
|
||||
│ - 列表页:分页查 book + 每本完成进度条 │
|
||||
│ - 详情页:doc 列表 + 跳 html / json 在线预览 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 ER 关系
|
||||
|
||||
```
|
||||
┌────────────┐ 1 N ┌────────────────┐ 1 N ┌──────────────┐
|
||||
│ book │─────────│ book_document │─────────│ document_file│
|
||||
│ 教辅 │ │ 文档 │ │ 文件(多形态) │
|
||||
└────────────┘ └────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### 2.2 实体字段
|
||||
|
||||
#### `book` 教辅(zip / 套书)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | BIGINT PK | 自增 |
|
||||
| `name` | VARCHAR(200) | 教辅名("2027版.曲一线3•2物理.广西版.一轮复习-突破训练")|
|
||||
| `subject` | VARCHAR(50) | 学科(物理/数学/语文/...)|
|
||||
| `grade` | VARCHAR(50) | 年级(高三/高一/...)|
|
||||
| `version` | VARCHAR(50) | 版本(人教版/广西版/2027版/...)|
|
||||
| `type` | VARCHAR(50) | 类型(突破训练/同步训练/真题/...)|
|
||||
| `zip_path` | VARCHAR(500) | 原始 zip 在对象存储的路径(如 `book/2027-物理-突破训练.zip`)|
|
||||
| `zip_sha256` | VARCHAR(64) UNIQUE | 原始 zip 哈希(幂等去重)|
|
||||
| `zip_size` | BIGINT | 原始 zip 字节数 |
|
||||
| `status` | ENUM | `pending` / `processing` / `done` / `failed` / `partial` |
|
||||
| `total_count` | INT | zip 内 docx 总数 |
|
||||
| `success_count` | INT | 成功转 HTML 数 |
|
||||
| `failed_count` | INT | 失败数 |
|
||||
| `error_msg` | TEXT | 失败原因 |
|
||||
| `created_at` | DATETIME | 创建时间 |
|
||||
| `updated_at` | DATETIME | 处理完成时间 |
|
||||
| `created_by` | VARCHAR(100) | 哪个 Agent / 哪个用户传 |
|
||||
| `is_deleted` | TINYINT | 软删标记(0/1)|
|
||||
|
||||
**唯一索引**: `uk_name_version (name, version, is_deleted)` 防同名教辅重复创建
|
||||
|
||||
#### `book_document` 文档(zip 里的每个 docx 一条)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | BIGINT PK | 自增 |
|
||||
| `book_id` | BIGINT FK→book.id | 属于哪本教辅 |
|
||||
| `doc_id` | VARCHAR(64) UNIQUE | **本服务的核心 ID**(算法见下)|
|
||||
| `original_filename` | VARCHAR(200) | 原始文件名 |
|
||||
| `original_path` | VARCHAR(500) | zip 内的相对路径 |
|
||||
| `title` | VARCHAR(200) | 解析出的标题(如能从 docx 提取)|
|
||||
| `doc_type` | ENUM | `课时作业` / `周末提升` / `阶段质量评估` / `综合评估` / `unknown` |
|
||||
| `order_index` | INT | 在教辅中的顺序(按文件名排序)|
|
||||
| `status` | ENUM | `pending` / `html_ready` / `json_ready` / `failed` |
|
||||
| `error_msg` | TEXT | 失败原因 |
|
||||
| `created_at` | DATETIME | |
|
||||
| `updated_at` | DATETIME | |
|
||||
|
||||
**`doc_id` 生成算法**(保证全局唯一 + 路径稳定):
|
||||
|
||||
```python
|
||||
doc_id = sha1(f"{book_id}|{original_path}").hexdigest()[:16]
|
||||
# 例: 5b3a2f1e9c0d4b7a
|
||||
```
|
||||
|
||||
- 同本书同路径永远同一 doc_id
|
||||
- 重复传同一 zip 不会重复入库(幂等)
|
||||
- 不带敏感信息(文件名脱敏)
|
||||
|
||||
**索引**: `uk_doc_id`, `idx_book_id (book_id, order_index)`
|
||||
|
||||
#### `document_file` 文件(HTML / JSON / 原 docx 多产物)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | BIGINT PK | 自增 |
|
||||
| `doc_id` | VARCHAR(64) FK→book_document.doc_id | 关联到文档 |
|
||||
| `file_type` | ENUM | `docx` / `html` / `json` / `preview_png` / `error_log` |
|
||||
| `object_key` | VARCHAR(500) | 对象存储 key |
|
||||
| `object_bucket` | VARCHAR(50) | 桶名 |
|
||||
| `size` | BIGINT | 字节数 |
|
||||
| `checksum` | VARCHAR(64) | sha256 |
|
||||
| `url` | VARCHAR(500) | 公开 URL(如果是公开桶)|
|
||||
| `meta` | JSON | 附加信息:公式数、图片数、字符数等 |
|
||||
| `created_at` | DATETIME | |
|
||||
|
||||
**索引**: `uk_doc_type (doc_id, file_type)`, `idx_bucket_key (object_bucket, object_key)`
|
||||
|
||||
**一个 doc 对应 3-5 条 file 记录**: 原 docx + html + json + (可选) 错误日志
|
||||
|
||||
---
|
||||
|
||||
## 3. 对象存储路径结构
|
||||
|
||||
```
|
||||
<bucket>/
|
||||
└── book/
|
||||
└── <book_id_or_slug>/ # book-{id}-{slug}
|
||||
└── docs/
|
||||
└── <doc_id>/ # sha1 短 hash
|
||||
├── source.docx # 原 docx(备份用)
|
||||
├── output.html # 转的 HTML(含 MathJax)
|
||||
├── structure.json # Agent 后续结构化输出
|
||||
├── preview.png # 可选:首页截图
|
||||
└── error.log # 仅失败时有
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- 同 doc_id 所有产物都在一个目录
|
||||
- 删教辅 → 删整个 `<book_id>/` 前缀
|
||||
- 列表时一次 list `<book_id>/docs/` 拿到全部
|
||||
|
||||
---
|
||||
|
||||
## 4. MCP 工具集
|
||||
|
||||
### 4.1 写入类
|
||||
|
||||
| # | 工具 | 签名 | 返回 |
|
||||
|---|---|---|---|
|
||||
| 1 | `create_book` | `(name, zip_path, subject?, grade?, version?, type?, created_by?)` | `{book_id, status, total_count}` |
|
||||
| 2 | `add_document` | `(book_id, original_filename, original_path, zip_size?)` | `{doc_id, order_index, status}` |
|
||||
| 3 | `mark_html_ready` | `(doc_id, html_url, html_size, stats: {formulas, images, chars})` | `{ok, file_id}` |
|
||||
| 4 | `mark_json_ready` | `(doc_id, json_url, json_size, schema_version)` | `{ok, file_id}` |
|
||||
| 5 | `mark_failed` | `(doc_id, error_msg, stage: "html" \| "json" \| "upload")` | `{ok, status}` |
|
||||
| 6 | `update_book_status` | `(book_id, status, error_msg?)` | `{ok}` |
|
||||
| 15 | `upload_to_rustfs` | `(local_path, key, bucket?)` | `{url, key, size, checksum}` |
|
||||
|
||||
### 4.2 查询类
|
||||
|
||||
| # | 工具 | 签名 | 返回 |
|
||||
|---|---|---|---|
|
||||
| 7 | `get_book` | `(book_id)` | `{book + documents[] + files[]}` |
|
||||
| 8 | `list_books` | `(status?, page?, page_size?, keyword?)` | `{books[], total, page, page_size}` |
|
||||
| 9 | `get_document` | `(doc_id)` | `{document + files[]}` |
|
||||
| 10 | `list_documents` | `(book_id, status?, page?, page_size?)` | `{documents[], total}` |
|
||||
| 11 | `get_document_files` | `(doc_id)` | `{files[]}` |
|
||||
| 12 | `get_document_url` | `(doc_id, file_type: "html"\|"json"\|"docx")` | `{url, expires_in}` |
|
||||
|
||||
### 4.3 辅助类
|
||||
|
||||
| # | 工具 | 签名 | 返回 |
|
||||
|---|---|---|---|
|
||||
| 13 | `delete_book` | `(book_id, hard?: bool)` | `{ok, deleted_files}` |
|
||||
| 14 | `retry_failed` | `(book_id)` | `{retried_count, ok, failed_count}` |
|
||||
|
||||
### 4.4 工具数量
|
||||
|
||||
- **v1 最小可用**: 8 个(`create_book` / `add_document` / `mark_html_ready` / `mark_json_ready` / `mark_failed` / `update_book_status` / `get_book` / `list_books`)
|
||||
- **v2 完整**: 加 `get_document` / `list_documents` / `get_document_files` / `get_document_url`
|
||||
- **v3 兜底**: 加 `delete_book` / `retry_failed` / `upload_to_rustfs`
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术栈选型
|
||||
|
||||
| 层 | 选型 | 理由 |
|
||||
|---|---|---|
|
||||
| MCP 框架 | `fastmcp` (Python 3.13) | 跟 `wordparse_server.py` 一致,**<10 行起 server** |
|
||||
| HTTP 框架 | 不另起 — fastmcp 自动暴露 HTTP | 省一个服务 |
|
||||
| 数据库 | MySQL / PolarDB | 跟 jby 同栈;`SQLAlchemy` 2.0 + `alembic` |
|
||||
| 对象存储 | RustFS `fs.code-lab.cn`(你已有)| 同 wordparse |
|
||||
| Word→HTML | 复用 `wordparse_server.py` 的 `docx_to_html` | 不重写,自动清洗已集成 |
|
||||
| HTML→JSON | Agent 调 LLM 自己做 | 复杂结构化逻辑,放业务侧 |
|
||||
| 配置 | YAML | 跟 wordparse 一致 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 项目目录
|
||||
|
||||
```
|
||||
book-mcp/
|
||||
├── config.yaml # db / rustfs / 默认桶
|
||||
├── book_mcp_server.py # MCP 入口(fastmcp)
|
||||
├── db/
|
||||
│ ├── models.py # SQLAlchemy ORM
|
||||
│ ├── migrations/ # alembic
|
||||
│ └── schema.sql # 一份纯 SQL(给 DBA / 手动建表用)
|
||||
├── services/
|
||||
│ ├── book_service.py # book CRUD
|
||||
│ ├── document_service.py # doc CRUD
|
||||
│ ├── file_service.py # file CRUD
|
||||
│ └── rustfs_service.py # 上传 / 删
|
||||
├── tests/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键设计决策
|
||||
|
||||
| 问题 | 决策 | 理由 |
|
||||
|---|---|---|
|
||||
| 用 `book_id` 还是 `name` 查重? | name + version 唯一索引 | 同名教辅重复传不创建新 book |
|
||||
| 同一 zip 多次上传? | 幂等(按 zip sha256 去重 book)| 防呆 |
|
||||
| docx 处理失败怎么办? | `book.status = "partial"`,不阻塞整本 | 容错,UI 标黄提醒 |
|
||||
| html 公开还是私有? | 公开(读教辅是低频操作)| 简化 URL 生成 |
|
||||
| json 谁生成? | Agent 调 LLM | 强业务相关,不放 MCP |
|
||||
| MCP 一次返回多少文档? | `list_documents` 分页 50/页 | 防大书 OOM |
|
||||
| 数据库有没有? | 独立新库 `book_mcp` | 避免污染 jby schema |
|
||||
| 删除能级? | 软删(`is_deleted` 字段)| 防误删 |
|
||||
| 后续页面怎么查? | 直接调 MCP 的查询类工具(fastmcp 自动暴露 HTTP)| 不写新 API |
|
||||
|
||||
---
|
||||
|
||||
## 8. v1 范围(最小可用)
|
||||
|
||||
**做这 8 个工具就够用了**:
|
||||
|
||||
1. `create_book` — 必做
|
||||
2. `add_document` — 必做
|
||||
3. `mark_html_ready` — 必做
|
||||
4. `mark_json_ready` — 必做
|
||||
5. `mark_failed` — 必做
|
||||
6. `update_book_status` — 必做
|
||||
7. `get_book` — 必做
|
||||
8. `list_books` — 必做
|
||||
|
||||
**v2 再加**: `get_document` / `list_documents` / `get_document_files` / `get_document_url` / `delete_book` / `retry_failed`
|
||||
|
||||
**v1 不做**:
|
||||
|
||||
- 鉴权(内网/白名单)
|
||||
- 全文搜索(用对象存储 + ES 后续加)
|
||||
- 文件版本管理(覆盖式)
|
||||
- 异步任务队列(同步够用,每本 ~3-5 分钟)
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险 & 备选
|
||||
|
||||
| 风险 | 应对 |
|
||||
|---|---|
|
||||
| 上传 1000+ docx 的大教辅会卡 Agent | `add_document` 一次传 1 个,避免事务过大;Agent 用并发池 |
|
||||
| docx 转 HTML 全失败(脏文件)| 用之前加的 `_clean_docx_xml` 自动清洗 |
|
||||
| 对象存储 URL 变(key 改了)| 数据库记 `object_key`,URL 重新生成 |
|
||||
| MCP server 自己挂了 | launchd 保活 + 自动重启(参考 paop gateway)|
|
||||
|
||||
---
|
||||
|
||||
## 10. 待确认事项
|
||||
|
||||
| 待定 | 选项 |
|
||||
|---|---|
|
||||
| **数据库** | A. 复用 jby PolarDB(`questions-bank` 库加表)<br>B. 起新库 `book_mcp`<br>C. 用 SQLite(开发期够用)|
|
||||
| **MCP 名字** | `book-mcp` / `exam-book-mcp` / `teach-assist-mcp` |
|
||||
| **桶** | 复用 `scan` 还是新开 `book` 桶? |
|
||||
| **写入控制** | 要不要签名 URL(防外链)?还是公开? |
|
||||
| **MCP 端口** | 8080 / 8100 / 复用 8089 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A: 数据库 schema 草稿
|
||||
|
||||
```sql
|
||||
CREATE TABLE book (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
subject VARCHAR(50),
|
||||
grade VARCHAR(50),
|
||||
version VARCHAR(50),
|
||||
type VARCHAR(50),
|
||||
zip_path VARCHAR(500) NOT NULL,
|
||||
zip_sha256 VARCHAR(64) NOT NULL,
|
||||
zip_size BIGINT NOT NULL DEFAULT 0,
|
||||
status ENUM('pending','processing','done','failed','partial') NOT NULL DEFAULT 'pending',
|
||||
total_count INT NOT NULL DEFAULT 0,
|
||||
success_count INT NOT NULL DEFAULT 0,
|
||||
failed_count INT NOT NULL DEFAULT 0,
|
||||
error_msg TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(100),
|
||||
is_deleted TINYINT NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY uk_zip_sha (zip_sha256),
|
||||
UNIQUE KEY uk_name_version (name, version, is_deleted)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE book_document (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
book_id BIGINT NOT NULL,
|
||||
doc_id VARCHAR(64) NOT NULL,
|
||||
original_filename VARCHAR(200) NOT NULL,
|
||||
original_path VARCHAR(500) NOT NULL,
|
||||
title VARCHAR(200),
|
||||
doc_type ENUM('课时作业','周末提升','阶段质量评估','综合评估','unknown') NOT NULL DEFAULT 'unknown',
|
||||
order_index INT NOT NULL DEFAULT 0,
|
||||
status ENUM('pending','html_ready','json_ready','failed') NOT NULL DEFAULT 'pending',
|
||||
error_msg TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_doc_id (doc_id),
|
||||
INDEX idx_book_order (book_id, order_index),
|
||||
CONSTRAINT fk_book_doc FOREIGN KEY (book_id) REFERENCES book(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE document_file (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
doc_id VARCHAR(64) NOT NULL,
|
||||
file_type ENUM('docx','html','json','preview_png','error_log') NOT NULL,
|
||||
object_key VARCHAR(500) NOT NULL,
|
||||
object_bucket VARCHAR(50) NOT NULL,
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
checksum VARCHAR(64),
|
||||
url VARCHAR(500),
|
||||
meta JSON,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_doc_type (doc_id, file_type),
|
||||
INDEX idx_bucket_key (object_bucket, object_key),
|
||||
CONSTRAINT fk_file_doc FOREIGN KEY (doc_id) REFERENCES book_document(doc_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录 B: 状态机
|
||||
|
||||
```
|
||||
book.status 状态机:
|
||||
pending → processing → done (全部成功)
|
||||
└──→ partial (有失败)
|
||||
└──→ failed (完全失败,异常中断)
|
||||
|
||||
book_document.status 状态机:
|
||||
pending → html_ready → json_ready (正常完成)
|
||||
└──→ failed (任何一步失败,可重试)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**END** — 等 Quentin 确认待定项后开干。
|
||||
213
docs/deploy.md
Normal file
213
docs/deploy.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# book-mcp 部署文档
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Agent (任意 Agent 客户端)
|
||||
↓ HTTP/JSON-RPC
|
||||
book-mcp (8100) ──┬─→ MongoDB (27018/book_mcp) ← docker container
|
||||
└─→ RustFS (fs.code-lab.cn/scan/) ← 对象存储
|
||||
└─→ wordparse (8089) ← docker container docx4j
|
||||
```
|
||||
|
||||
## 组件
|
||||
|
||||
| 组件 | 位置 | 端口 | 启动方式 |
|
||||
|---|---|---|---|
|
||||
| **book-mcp** | `~/WorkSpace/book-mcp/book_mcp_server.py` | 8100 | launchd `ai.book-mcp.server` |
|
||||
| **MongoDB** | docker container `book-mcp-mongo` | 27018 | `docker start book-mcp-mongo` |
|
||||
| **RustFS** | `fs.code-lab.cn` | 443 | 远程 |
|
||||
| **wordparse (docx4j)** | docker container `wordparse-test` | 8089 | `docker start wordparse-test` |
|
||||
|
||||
## 安装(一次性)
|
||||
|
||||
### 1. MongoDB 容器
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name book-mcp-mongo \
|
||||
-p 27018:27017 \
|
||||
-e MONGO_INITDB_ROOT_USERNAME=book_admin \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD=book_mongo_2026 \
|
||||
-e MONGO_INITDB_DATABASE=book_mcp \
|
||||
-v book-mcp-mongo-data:/data/db \
|
||||
--restart unless-stopped \
|
||||
mongo:8.0
|
||||
```
|
||||
|
||||
### 2. Python venv(一次性)
|
||||
|
||||
```bash
|
||||
python3.13 -m venv /tmp/wordparse_venv
|
||||
/tmp/wordparse_venv/bin/pip install -r ~/WorkSpace/book-mcp/requirements.txt
|
||||
```
|
||||
|
||||
依赖:`fastmcp boto3 pymongo pyyaml`
|
||||
|
||||
### 3. MongoDB 索引
|
||||
|
||||
```bash
|
||||
/tmp/wordparse_venv/bin/python ~/WorkSpace/book-mcp/book_mcp_server.py --init-db
|
||||
```
|
||||
|
||||
输出:
|
||||
```
|
||||
✓ MongoDB 已就绪(db: book_mcp, collections: [...])
|
||||
✓ 8 个索引创建完成
|
||||
```
|
||||
|
||||
### 4. LaunchAgent
|
||||
|
||||
```bash
|
||||
# 装(已装好:~/Library/LaunchAgents/ai.book-mcp.server.plist)
|
||||
launchctl load -w ~/Library/LaunchAgents/ai.book-mcp.server.plist
|
||||
```
|
||||
|
||||
## 日常运维
|
||||
|
||||
### 状态查询
|
||||
|
||||
```bash
|
||||
# launchd 状态
|
||||
launchctl print gui/501/ai.book-mcp.server | grep -E "state|pid"
|
||||
|
||||
# 端口
|
||||
lsof -i :8100
|
||||
|
||||
# 健康
|
||||
curl http://127.0.0.1:8100/mcp
|
||||
# fastmcp 在 /mcp 走 HTTP
|
||||
|
||||
# 实时日志
|
||||
tail -f ~/.hermes/services/book-mcp/logs/server.log
|
||||
```
|
||||
|
||||
### 重启
|
||||
|
||||
```bash
|
||||
# 软重启(SIGTERM,launchd 30s ThrottleInterval)
|
||||
kill $(lsof -ti :8100)
|
||||
|
||||
# launchd 自动拉起
|
||||
sleep 5
|
||||
ps aux | grep book_mcp_server | grep -v grep
|
||||
```
|
||||
|
||||
### 调试
|
||||
|
||||
```bash
|
||||
# 1. 跑 e2e 测试
|
||||
/tmp/wordparse_venv/bin/python ~/WorkSpace/book-mcp/tests/test_e2e.py
|
||||
|
||||
# 2. 直接调 MCP 工具(fastmcp client)
|
||||
/tmp/wordparse_venv/bin/python << 'PYEOF'
|
||||
import asyncio
|
||||
from fastmcp.client import Client
|
||||
async def main():
|
||||
async with Client("http://127.0.0.1:8100/mcp") as c:
|
||||
r = await c.call_tool('health', {})
|
||||
print(r.data)
|
||||
asyncio.run(main())
|
||||
PYEOF
|
||||
|
||||
# 3. mongo 直连
|
||||
/tmp/wordparse_venv/bin/python << 'PYEOF'
|
||||
from db import get_collection, COL_BOOK
|
||||
print(f"books count: {get_collection(COL_BOOK).count_documents({})}")
|
||||
PYEOF
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
`~/WorkSpace/book-mcp/config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8100 # MCP server 端口
|
||||
database:
|
||||
url: mongodb://book_admin:book_mongo_2026@127.0.0.1:27018/book_mcp?authSource=admin
|
||||
db_name: book_mcp
|
||||
rustfs:
|
||||
endpoint: https://fs.code-lab.cn
|
||||
default_bucket: scan
|
||||
wordparse:
|
||||
url: http://127.0.0.1:8089/api/v3/server_office_file/file/parse
|
||||
timeout: 600
|
||||
```
|
||||
|
||||
## MCP 工具列表(15 个)
|
||||
|
||||
| # | 工具 | 类别 |
|
||||
|---|---|---|
|
||||
| 1 | create_book | 写入 |
|
||||
| 2 | add_document | 写入 |
|
||||
| 3 | mark_html_ready | 写入 |
|
||||
| 4 | mark_json_ready | 写入 |
|
||||
| 5 | mark_failed | 写入 |
|
||||
| 6 | update_book_status | 写入 |
|
||||
| 7 | get_book | 查询 |
|
||||
| 8 | list_books | 查询 |
|
||||
| 9 | get_document | 查询 |
|
||||
| 10 | list_documents | 查询 |
|
||||
| 11 | get_document_url | 查询 |
|
||||
| 12 | upload_to_rustfs | 辅助 |
|
||||
| 13 | soft_delete_book | 辅助 |
|
||||
| 14 | retry_failed | 辅助 |
|
||||
| 15 | health | 诊断 |
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
zip 上传 RustFS
|
||||
↓ create_book(zip_sha256)
|
||||
→ book.id
|
||||
↓ 循环每 docx
|
||||
→ add_document(filename, path)
|
||||
→ doc.doc_id = sha1(book_id|path)[:16]
|
||||
→ wordparse.convert_one (cleaner + docx4j + MathJax)
|
||||
→ RustFS upload → mark_html_ready(url)
|
||||
→ Agent 解析 HTML → structure.json
|
||||
→ RustFS upload → mark_json_ready(url)
|
||||
→ update_book_status("done")
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|---|---|---|
|
||||
| `mongo` health=False | 容器没起 | `docker start book-mcp-mongo` |
|
||||
| `wordparse` health=False | docx4j 容器没起 | `docker start wordparse-test` |
|
||||
| 8100 端口未 listen | 进程挂了 | `kill $(lsof -ti :8100)` 走 launchd 自动拉起 |
|
||||
| `add_document` 报 NoneType | 数据库索引问题(已修复) | 重跑 `--init-db` |
|
||||
| 上传 403 Forbidden | 桶不存在 | 检查 `default_bucket` 是否在 RustFS 实际存在 |
|
||||
| e2e 测试报 `uk_name_version` | 同名教辅已存在 | 改 name 或 version 加时间戳 |
|
||||
|
||||
## 备份
|
||||
|
||||
- **MongoDB 数据**:`book-mcp-mongo-data` docker volume(在 docker 里)
|
||||
- **RustFS 文件**:`https://fs.code-lab.cn/scan/book/<book_id>/...`
|
||||
- **代码**:`~/WorkSpace/book-mcp/`
|
||||
|
||||
## 升级路径
|
||||
|
||||
```bash
|
||||
# 1. 拉新代码
|
||||
cd ~/WorkSpace/book-mcp
|
||||
git pull # 假设推到 git
|
||||
|
||||
# 2. 升级依赖
|
||||
/tmp/wordparse_venv/bin/pip install -r requirements.txt --upgrade
|
||||
|
||||
# 3. 重启 launchd
|
||||
kill $(lsof -ti :8100)
|
||||
sleep 5
|
||||
ps aux | grep book_mcp_server | grep -v grep
|
||||
```
|
||||
|
||||
## 相关链接
|
||||
|
||||
- 设计文档:`docs/book-mcp-design.md`
|
||||
- Schema:`db/schema_doc.md`
|
||||
- wordparse 工具:`services/wordparse/`
|
||||
- 启动日志:`~/.hermes/services/book-mcp/logs/server.log`
|
||||
- 错误日志:`~/.hermes/services/book-mcp/logs/server.error.log`
|
||||
212
frontend/.cursorrules
Normal file
212
frontend/.cursorrules
Normal file
@@ -0,0 +1,212 @@
|
||||
# Frontend Cursor Rules
|
||||
|
||||
## 1. 基础规范
|
||||
- Vue 3 Composition API (`<script setup>`)
|
||||
- TypeScript
|
||||
- Arco Design Vue 2.x(组件自动导入)
|
||||
|
||||
## 2. 目录结构
|
||||
- **API (`src/api/`)**: API 接口定义
|
||||
- 按功能模块组织文件(如 `user.ts`、`dashboard.ts`)
|
||||
- 使用 Axios 进行 HTTP 请求
|
||||
- 统一在 `src/api/interceptor.ts` 配置拦截器
|
||||
- **组件 (`src/components/`)**: 全局可复用组件
|
||||
- 按功能模块组织子目录
|
||||
- 全局组件在 `src/components/index.ts` 中注册
|
||||
- 使用 Arco Design 组件时无需手动导入(自动导入已配置)
|
||||
- 注意:模块专用组件应放在对应模块目录下的 `components/` 文件夹,而非全局 `src/components/`
|
||||
- **视图 (`src/views/`)**: 页面级组件
|
||||
- 按功能模块组织目录
|
||||
- 每个模块包含 `index.vue` 和 `locale/` 目录(国际化文案)
|
||||
- 模块专用组件放在模块目录下的 `components/` 文件夹(如 `src/views/User/UserInfo/components/`)
|
||||
- **路由 (`src/router/`)**: 路由配置
|
||||
- 路由定义在 `router/routes/` 目录,按模块组织
|
||||
- 路由守卫在 `router/guard/` 目录
|
||||
- **状态管理 (`src/store/`)**: Pinia Store
|
||||
- 按模块组织 store(如 `user/`、`app/`)
|
||||
- 使用 `defineStore` 定义 store
|
||||
- **工具 (`src/utils/`)**: 通用工具函数
|
||||
- **Hooks (`src/hooks/`)**: 组合式函数
|
||||
- 可复用的 Composition API 逻辑
|
||||
|
||||
## 2.1. 命名规范(重要)
|
||||
- **目录命名**:必须使用**驼峰命名法(PascalCase)**
|
||||
- ✅ `UserManage/`、`QuestionBank/`、`PaperManage/`、`UserInfo/`
|
||||
- ❌ `user-manage/`、`question-bank/`、`user-info/`
|
||||
- **组件文件命名**:必须使用**驼峰命名法(PascalCase)**
|
||||
- ✅ `UserList.vue`、`RoleManage.vue`、`MenuManage.vue`、`UserInfoHeader.vue`
|
||||
- ❌ `user-list.vue`、`role-manage.vue`、`user-info-header.vue`
|
||||
- **独立子页面**:必须单独建立文件夹
|
||||
- 如果页面有独立的子页面(如用户详情页),应单独建立文件夹
|
||||
- 示例:`User/UserInfo/`、`User/UserSetting/`
|
||||
- 每个子页面目录包含 `index.vue` 和可选的 `components/` 文件夹
|
||||
|
||||
## 2.2. 组件化原则
|
||||
- **公共组件**:放在 `src/components/` 目录下
|
||||
- 可在多个页面复用的组件
|
||||
- **页面私有组件**:放在页面目录下的 `components/` 文件夹
|
||||
- 仅当前页面使用的组件
|
||||
- 示例:`User/UserInfo/components/UserInfoHeader.vue`
|
||||
- **页面组件化**:能够拆成组件的都组件化
|
||||
- 将复杂页面拆分为多个小组件
|
||||
- 示例:`UserManage/index.vue` 包含 `UserList`、`RoleManage`、`MenuManage` 三个组件
|
||||
|
||||
## 2.3. 页面结构示例
|
||||
```
|
||||
views/
|
||||
├── User/ # 用户个人信息页面
|
||||
│ ├── UserInfo/ # 用户信息子页面(独立文件夹)
|
||||
│ │ ├── components/ # 私有组件(仅当前页面用)
|
||||
│ │ │ ├── UserInfoHeader.vue
|
||||
│ │ │ ├── LatestActivity.vue
|
||||
│ │ │ └── MyProject.vue
|
||||
│ │ └── index.vue # 详情页主文件
|
||||
│ └── UserSetting/ # 用户设置子页面(独立文件夹)
|
||||
│ ├── components/
|
||||
│ │ ├── UserPanel.vue
|
||||
│ │ └── BasicInformation.vue
|
||||
│ └── index.vue
|
||||
│
|
||||
└── UserManage/ # 用户管理页面(管理员功能)
|
||||
├── components/ # 组件化(可复用)
|
||||
│ ├── UserList.vue # 用户列表组件
|
||||
│ ├── RoleManage.vue # 角色管理组件
|
||||
│ └── MenuManage.vue # 菜单管理组件
|
||||
└── index.vue # 主页面(标签页容器)
|
||||
```
|
||||
|
||||
## 3. 代码风格
|
||||
|
||||
### 格式化规范(遵循 `.prettierrc.js`)
|
||||
- **缩进**: 2 个空格(`tabWidth: 2`)
|
||||
- **引号**: 单引号(`singleQuote: true`)
|
||||
- **分号**: 必须使用分号(`semi: true`)
|
||||
- **行宽**: 80 字符(`printWidth: 80`)
|
||||
- **对象属性引号**: 保持一致(`quoteProps: 'consistent'`)
|
||||
- **HTML 空白**: 严格模式(`htmlWhitespaceSensitivity: 'strict'`)
|
||||
- **Vue 脚本样式缩进**: 启用(`vueIndentScriptAndStyle: true`)
|
||||
|
||||
### 代码检查规范(遵循 `.eslintrc.js`)
|
||||
- **代码风格**: Airbnb JavaScript Style Guide
|
||||
- **TypeScript**: 使用 `@typescript-eslint/recommended` 规则
|
||||
- **Vue 3**: 使用 `plugin:vue/vue3-recommended` 规则
|
||||
- **导入**: 使用 `plugin:import` 规则,支持 TypeScript 路径解析
|
||||
- **关键规则**:
|
||||
- 事件名使用 camelCase(`vue/custom-event-name-casing`)
|
||||
- 允许 `@ts-ignore` 注释(`@typescript-eslint/ban-ts-comment: 0`)
|
||||
- 允许 `any` 类型(`@typescript-eslint/no-explicit-any: 0`)
|
||||
- 导入时不需要扩展名(`import/extensions`)
|
||||
- 生产环境禁止 `debugger`,开发环境允许
|
||||
|
||||
### TypeScript
|
||||
- 使用 `camelCase` 命名变量和函数
|
||||
- 使用 `PascalCase` 命名类和组件
|
||||
- 使用类型提示,但不需要比 Python 更严格
|
||||
- 避免使用 `abstract` 类和 `protected` 修饰符
|
||||
- 命名方式需与后端 Python 代码保持一致(API 调用时保持命名一致性)
|
||||
|
||||
### Vue 组件
|
||||
- 使用 `<script setup>` 语法
|
||||
- **组件文件名必须使用 PascalCase**(如 `UserProfile.vue`、`UserList.vue`)
|
||||
- **目录名必须使用 PascalCase**(如 `UserManage/`、`QuestionBank/`)
|
||||
- Props 和 Emits 使用 `defineProps` 和 `defineEmits`
|
||||
- 使用 `ref` 和 `reactive` 管理响应式状态
|
||||
- 使用 `computed` 和 `watch` 处理派生状态和副作用
|
||||
- 事件名使用 camelCase(如 `@update:value`)
|
||||
|
||||
### 样式
|
||||
- 使用 Less 作为 CSS 预处理器
|
||||
- 组件样式使用 `<style lang="less" scoped>`
|
||||
- 全局样式在 `src/assets/style/global.less`
|
||||
- 使用 Arco Design CSS 变量(如 `var(--color-bg-1)`)
|
||||
- 响应式使用 Less 变量 `@screen-*`(定义在 `src/assets/style/breakpoint.less`)
|
||||
|
||||
## 4. UI 组件使用
|
||||
|
||||
### Arco Design 组件
|
||||
- **优先使用 Arco Design 组件**: 优先使用 Arco Design Vue 提供的组件
|
||||
- **自动导入**: Arco 组件已配置自动导入,无需手动 import
|
||||
- **主题变量**: 使用 Arco Design CSS 变量,支持主题切换
|
||||
- **图标**: 使用 `@arco-design/web-vue/es/icon` 中的图标
|
||||
- **样式修改**: 不要擅自修改 Arco Design 组件的默认样式,除非收到明确的修改命令
|
||||
|
||||
### 自定义组件
|
||||
- 放置在 `src/components/` 目录
|
||||
- 按功能模块组织子目录
|
||||
- 全局组件在 `src/components/index.ts` 中注册
|
||||
|
||||
## 5. API 调用
|
||||
|
||||
### 请求配置
|
||||
- 使用 `src/api/interceptor.ts` 中的 Axios 实例
|
||||
- 统一错误处理在拦截器中配置
|
||||
- Token 认证通过请求头自动添加
|
||||
|
||||
### API 文件组织
|
||||
- 按功能模块组织 API 文件(如 `user.ts`、`dashboard.ts`)
|
||||
- 导出函数命名使用 camelCase
|
||||
- 使用 TypeScript 类型定义请求和响应
|
||||
|
||||
## 6. 状态管理
|
||||
|
||||
### Pinia Store
|
||||
- 使用 `defineStore` 定义 store
|
||||
- Store 文件按模块组织在 `src/store/modules/` 目录
|
||||
- 使用组合式 API 风格(`setup` 函数)
|
||||
|
||||
### 状态命名
|
||||
- State 使用 camelCase
|
||||
- Actions 使用 camelCase,动词开头(如 `fetchUserInfo`)
|
||||
- Getters 使用 camelCase
|
||||
|
||||
## 7. 路由
|
||||
|
||||
### 路由配置
|
||||
- 路由定义在 `router/routes/` 目录,按模块组织
|
||||
- 使用懒加载:`component: () => import('@/views/xxx/index.vue')`
|
||||
- 路由元信息在 `meta` 字段中定义
|
||||
|
||||
### 路由守卫
|
||||
- 认证守卫:`router/guard/userLoginInfo.ts`
|
||||
- 权限守卫:`router/guard/permission.ts`
|
||||
- 全局守卫:`router/guard/index.ts`
|
||||
|
||||
## 8. 国际化
|
||||
- 使用 `useI18n()` 获取 `t` 函数
|
||||
- 页面文案:`views/[module]/locale/`,全局文案:`src/locale/`
|
||||
- 文案 key 使用点号分隔(如 `user.login.title`)
|
||||
|
||||
## 9. 数据可视化
|
||||
|
||||
### ECharts
|
||||
- 使用 `vue-echarts` 封装 ECharts
|
||||
- 图表组件统一在 `src/components/chart/` 目录
|
||||
- 图表配置使用 `hooks/chart-option.ts` 统一管理
|
||||
|
||||
## 10. 开发规范
|
||||
|
||||
### 环境变量
|
||||
- 使用 Vite 环境变量(`VITE_*` 前缀)
|
||||
- 开发环境:`.env.development`
|
||||
- 生产环境:`.env.production`
|
||||
- API 地址:`VITE_API_BASE_URL`
|
||||
|
||||
### Mock 数据
|
||||
- 开发环境支持 Mock.js
|
||||
- Mock 配置在 `src/mock/` 目录
|
||||
- 使用 `src/utils/setup-mock.ts` 设置 Mock
|
||||
|
||||
### 代码质量
|
||||
- **ESLint**: 代码检查(配置在 `.eslintrc.js`),遵循 Airbnb 风格和 Vue 3 规范
|
||||
- **Prettier**: 代码格式化(配置在 `.prettierrc.js`),2空格缩进、单引号、分号
|
||||
- **Stylelint**: 样式检查(配置在 `.stylelintrc.js`)
|
||||
- **lint-staged**: 提交前自动格式化(配置在 `package.json`)
|
||||
- **重要**: 生成的代码必须符合 Prettier 和 ESLint 规范,避免格式化错误
|
||||
|
||||
## 11. 性能优化
|
||||
- 路由级别懒加载
|
||||
- 组件按需加载
|
||||
|
||||
## 12. 响应式设计
|
||||
- 使用 Less 变量 `@screen-xs` 到 `@screen-xxl`
|
||||
- 使用 `hooks/responsive.ts` 获取响应式状态
|
||||
5
frontend/.env.development
Normal file
5
frontend/.env.development
Normal file
@@ -0,0 +1,5 @@
|
||||
# book-mcp 前端 — 开发环境(用 vite proxy 转发 /mcp)
|
||||
VITE_API_BASE_URL=/mcp
|
||||
VITE_TRACKING_APP_KEY=book-mcp-vue
|
||||
VITE_TRACKING_REPORT_URL=/api/v1/tracking/report
|
||||
VITE_TRACKING_SAMPLE_RATE=1
|
||||
8
frontend/.env.example
Normal file
8
frontend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
VITE_TRACKING_APP_KEY=doluedu-arco-admin-vue
|
||||
VITE_TRACKING_REPORT_URL=/api/v1/tracking/report
|
||||
VITE_TRACKING_SAMPLE_RATE=1
|
||||
|
||||
# 生产环境配置(生产环境使用 .env.production)
|
||||
# VITE_API_BASE_URL=https://api.example.com
|
||||
3
frontend/.env.production
Normal file
3
frontend/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_TRACKING_APP_KEY=doluedu-arco-admin-vue
|
||||
VITE_TRACKING_REPORT_URL=/api/v1/tracking/report
|
||||
VITE_TRACKING_SAMPLE_RATE=1
|
||||
3
frontend/.eslintignore
Normal file
3
frontend/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
/*.json
|
||||
/*.js
|
||||
dist
|
||||
70
frontend/.eslintrc.js
Normal file
70
frontend/.eslintrc.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
// Parser that checks the content of the <script> tag
|
||||
parser: '@typescript-eslint/parser',
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
'browser': true,
|
||||
'node': true,
|
||||
'vue/setup-compiler-macros': true,
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
// Airbnb JavaScript Style Guide https://github.com/airbnb/javascript
|
||||
'airbnb-base',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:import/recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: path.resolve(__dirname, './tsconfig.json'),
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 1,
|
||||
// Vue: Recommended rules to be closed or modify
|
||||
'vue/require-default-prop': 0,
|
||||
'vue/singleline-html-element-content-newline': 0,
|
||||
'vue/max-attributes-per-line': 0,
|
||||
// Vue: Add extra rules
|
||||
'vue/custom-event-name-casing': [2, 'camelCase'],
|
||||
'vue/no-v-text': 1,
|
||||
'vue/padding-line-between-blocks': 1,
|
||||
'vue/require-direct-export': 1,
|
||||
'vue/multi-word-component-names': 0,
|
||||
// Allow @ts-ignore comment
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/no-unused-vars': 1,
|
||||
'@typescript-eslint/no-empty-function': 1,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'import/extensions': [
|
||||
2,
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
|
||||
'no-param-reassign': 0,
|
||||
'prefer-regex-literals': 0,
|
||||
'import/no-extraneous-dependencies': 0,
|
||||
},
|
||||
};
|
||||
10
frontend/.gitignore
vendored
Normal file
10
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
4
frontend/.husky/commit-msg
Executable file
4
frontend/.husky/commit-msg
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
pnpm commitlint --edit $1
|
||||
4
frontend/.husky/pre-commit
Executable file
4
frontend/.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint-staged
|
||||
7
frontend/.prettierignore
Normal file
7
frontend/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
/dist/*
|
||||
.local
|
||||
.output.js
|
||||
/node_modules/**
|
||||
|
||||
**/*.svg
|
||||
**/*.sh
|
||||
9
frontend/.prettierrc.js
Normal file
9
frontend/.prettierrc.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
printWidth: 80,
|
||||
singleQuote: true,
|
||||
quoteProps: 'consistent',
|
||||
htmlWhitespaceSensitivity: 'strict',
|
||||
vueIndentScriptAndStyle: true,
|
||||
};
|
||||
30
frontend/.stylelintrc.js
Normal file
30
frontend/.stylelintrc.js
Normal file
@@ -0,0 +1,30 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'stylelint-config-standard',
|
||||
'stylelint-config-rational-order',
|
||||
'stylelint-config-prettier',
|
||||
'stylelint-config-recommended-vue',
|
||||
],
|
||||
defaultSeverity: 'warning',
|
||||
plugins: ['stylelint-order'],
|
||||
rules: {
|
||||
'at-rule-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreAtRules: ['plugin'],
|
||||
},
|
||||
],
|
||||
'rule-empty-line-before': [
|
||||
'always',
|
||||
{
|
||||
except: ['after-single-line-comment', 'first-nested'],
|
||||
},
|
||||
],
|
||||
'selector-pseudo-class-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignorePseudoClasses: ['deep'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
56
frontend/README.md
Normal file
56
frontend/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# book-mcp Frontend
|
||||
|
||||
DoLuEdu Arco Admin Vue 管理端骨架 + book-mcp 业务模块。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 1. 确保 book-mcp server 在跑 (8100)
|
||||
# 2. 装依赖
|
||||
npm install
|
||||
|
||||
# 3. dev server
|
||||
npm run dev # http://localhost:5176
|
||||
|
||||
# 4. 构建生产
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
- `vite.config.dev.ts` — dev proxy `/mcp → http://localhost:8100/mcp`
|
||||
- `.env.development` — `VITE_API_BASE_URL=/mcp`(用相对路径走 proxy)
|
||||
|
||||
## 业务模块
|
||||
|
||||
- `src/api/mcp.ts` — JSON-RPC 客户端(fastmcp 协议)
|
||||
- `src/api/book.ts` — book CRUD
|
||||
- `src/api/document.ts` — document / file CRUD + health
|
||||
- `src/views/BookManage/index.vue` — 列表页(路由 `/book/list`)
|
||||
- `src/views/BookManage/BookDetail/index.vue` — 详情页(路由 `/book/:id`)
|
||||
- `src/views/BookManage/components/BookList.vue` — 列表组件
|
||||
- `src/views/BookManage/components/DocumentItem.vue` — 文档折叠项
|
||||
|
||||
## MCP 后端
|
||||
|
||||
15 个工具,分类:
|
||||
|
||||
| 类别 | 工具 |
|
||||
|---|---|
|
||||
| 写入 | create_book / add_document / mark_html_ready / mark_json_ready / mark_failed / update_book_status |
|
||||
| 查询 | get_book / list_books / get_document / list_documents / get_document_url |
|
||||
| 辅助 | upload_to_rustfs / soft_delete_book / retry_failed |
|
||||
| 诊断 | health |
|
||||
|
||||
启动:launchd `ai.book-mcp.server`(端口 8100,stateless_http + json_response)。
|
||||
|
||||
## 模板来源
|
||||
|
||||
`DoLuEdu-Template/frontend/arco-admin-vue/` 复制而来。模板规则:禁止在模板里写业务,只在骨架基础上加。
|
||||
|
||||
## 关键约定
|
||||
|
||||
- Vue 3 + TypeScript + `<script setup>`
|
||||
- Arco Design Vue 2.x
|
||||
- 目录 PascalCase(`BookManage/` 不是 `book-manage/`)
|
||||
- API 用 axios 包 fastmcp JSON-RPC
|
||||
3
frontend/babel.config.js
Normal file
3
frontend/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: ['@vue/babel-plugin-jsx'],
|
||||
};
|
||||
3
frontend/commitlint.config.js
Normal file
3
frontend/commitlint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
};
|
||||
15
frontend/components.d.ts
vendored
Normal file
15
frontend/components.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import '@vue/runtime-core'
|
||||
|
||||
export {}
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
19
frontend/config/plugin/arcoResolver.ts
Normal file
19
frontend/config/plugin/arcoResolver.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* If you use the template method for development, you can use the unplugin-vue-components plugin to enable on-demand loading support.
|
||||
* 按需引入
|
||||
* https://github.com/antfu/unplugin-vue-components
|
||||
* https://arco.design/vue/docs/start
|
||||
* Although the Pro project is full of imported components, this plugin will be used by default.
|
||||
* 虽然Pro项目中是全量引入组件,但此插件会默认使用。
|
||||
*/
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { ArcoResolver } from 'unplugin-vue-components/resolvers';
|
||||
|
||||
export default function configArcoResolverPlugin() {
|
||||
const arcoResolverPlugin = Components({
|
||||
dirs: [], // Avoid parsing src/components. 避免解析到src/components
|
||||
deep: false,
|
||||
resolvers: [ArcoResolver()],
|
||||
});
|
||||
return arcoResolverPlugin;
|
||||
}
|
||||
12
frontend/config/plugin/arcoStyleImport.ts
Normal file
12
frontend/config/plugin/arcoStyleImport.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Theme import
|
||||
* 样式按需引入
|
||||
* https://github.com/arco-design/arco-plugins/blob/main/packages/plugin-vite-vue/README.md
|
||||
* https://arco.design/vue/docs/start
|
||||
*/
|
||||
import { vitePluginForArco } from '@arco-plugins/vite-vue';
|
||||
|
||||
export default function configArcoStyleImportPlugin() {
|
||||
const arcoResolverPlugin = vitePluginForArco({});
|
||||
return arcoResolverPlugin;
|
||||
}
|
||||
18
frontend/config/plugin/visualizer.ts
Normal file
18
frontend/config/plugin/visualizer.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Generation packaging analysis
|
||||
* 生成打包分析
|
||||
*/
|
||||
import visualizer from 'rollup-plugin-visualizer';
|
||||
import { isReportMode } from '../utils';
|
||||
|
||||
export default function configVisualizerPlugin() {
|
||||
if (isReportMode()) {
|
||||
return visualizer({
|
||||
filename: './node_modules/.cache/visualizer/stats.html',
|
||||
open: true,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
9
frontend/config/utils/index.ts
Normal file
9
frontend/config/utils/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Whether to generate package preview
|
||||
* 是否生成打包报告
|
||||
*/
|
||||
export default {};
|
||||
|
||||
export function isReportMode(): boolean {
|
||||
return process.env.REPORT === 'true';
|
||||
}
|
||||
51
frontend/config/vite.config.base.ts
Normal file
51
frontend/config/vite.config.base.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx';
|
||||
import svgLoader from 'vite-svg-loader';
|
||||
import configArcoStyleImportPlugin from './plugin/arcoStyleImport';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
svgLoader({ svgoConfig: {} }),
|
||||
configArcoStyleImportPlugin(),
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: '@',
|
||||
replacement: resolve(__dirname, '../src'),
|
||||
},
|
||||
{
|
||||
find: 'assets',
|
||||
replacement: resolve(__dirname, '../src/assets'),
|
||||
},
|
||||
{
|
||||
find: 'vue-i18n',
|
||||
replacement: 'vue-i18n/dist/vue-i18n.cjs.js', // Resolve the i18n warning issue
|
||||
},
|
||||
{
|
||||
find: 'vue',
|
||||
replacement: 'vue/dist/vue.esm-bundler.js', // compile template
|
||||
},
|
||||
],
|
||||
extensions: ['.ts', '.js'],
|
||||
},
|
||||
define: {
|
||||
'process.env': {},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
modifyVars: {
|
||||
hack: `true; @import (reference) "${resolve(
|
||||
'src/assets/style/breakpoint.less'
|
||||
)}";`,
|
||||
},
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
32
frontend/config/vite.config.dev.ts
Normal file
32
frontend/config/vite.config.dev.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { mergeConfig } from 'vite';
|
||||
// import eslint from 'vite-plugin-eslint';
|
||||
import baseConfig from './vite.config.base';
|
||||
|
||||
export default mergeConfig(
|
||||
{
|
||||
mode: 'development',
|
||||
server: {
|
||||
host: '0.0.0.0', // 允许局域网访问
|
||||
port: 5176,
|
||||
open: false,
|
||||
fs: {
|
||||
strict: true,
|
||||
},
|
||||
proxy: {
|
||||
'/mcp': {
|
||||
target: 'http://localhost:8100',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/mcp/, '/mcp'),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// eslint({
|
||||
// cache: false,
|
||||
// include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.vue'],
|
||||
// exclude: ['node_modules'],
|
||||
// }),
|
||||
],
|
||||
},
|
||||
baseConfig
|
||||
);
|
||||
27
frontend/config/vite.config.prod.ts
Normal file
27
frontend/config/vite.config.prod.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { mergeConfig } from 'vite';
|
||||
import baseConfig from './vite.config.base';
|
||||
import configVisualizerPlugin from './plugin/visualizer';
|
||||
import configArcoResolverPlugin from './plugin/arcoResolver';
|
||||
|
||||
export default mergeConfig(
|
||||
{
|
||||
mode: 'production',
|
||||
plugins: [
|
||||
configVisualizerPlugin(),
|
||||
configArcoResolverPlugin(),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
arco: ['@arco-design/web-vue'],
|
||||
chart: ['echarts', 'vue-echarts'],
|
||||
vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 2000,
|
||||
},
|
||||
},
|
||||
baseConfig
|
||||
);
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DoLuEdu Template</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
14684
frontend/package-lock.json
generated
Normal file
14684
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
100
frontend/package.json
Normal file
100
frontend/package.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"name": "doluedu-arco-admin-vue-template",
|
||||
"description": "DoLuEdu Arco Design Pro Vue admin template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"author": "DoLuEdu",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite --config ./config/vite.config.dev.ts",
|
||||
"build": "vue-tsc --noEmit && vite build --config ./config/vite.config.prod.ts",
|
||||
"report": "cross-env REPORT=true npm run build",
|
||||
"preview": "npm run build && vite preview --host",
|
||||
"type:check": "vue-tsc --noEmit --skipLibCheck",
|
||||
"lint-staged": "npx lint-staged",
|
||||
"prepare": "node -e \"if (require('fs').existsSync('.git')) require('child_process').execSync('husky install', {stdio:'inherit'})\""
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,jsx,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.vue": [
|
||||
"stylelint --fix",
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"*.{less,css}": [
|
||||
"stylelint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.44.7",
|
||||
"@vueuse/core": "^9.3.0",
|
||||
"arco-design-pro-vue": "^2.7.3",
|
||||
"axios": "^0.24.0",
|
||||
"dayjs": "^1.11.5",
|
||||
"echarts": "^5.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mitt": "^3.0.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.0.23",
|
||||
"query-string": "^8.0.3",
|
||||
"sortablejs": "^1.15.0",
|
||||
"web-tracking-sdk": "^1.0.0",
|
||||
"vue": "^3.2.40",
|
||||
"vue-echarts": "^6.2.3",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-router": "^4.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arco-plugins/vite-vue": "^1.4.5",
|
||||
"@commitlint/cli": "^17.1.2",
|
||||
"@commitlint/config-conventional": "^17.1.0",
|
||||
"@types/lodash": "^4.14.186",
|
||||
"@types/mockjs": "^1.0.7",
|
||||
"@types/nprogress": "^0.2.0",
|
||||
"@types/sortablejs": "^1.15.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.40.0",
|
||||
"@typescript-eslint/parser": "^5.40.0",
|
||||
"@vitejs/plugin-vue": "^3.1.2",
|
||||
"@vitejs/plugin-vue-jsx": "^2.0.1",
|
||||
"@vue/babel-plugin-jsx": "^1.1.1",
|
||||
"consola": "^2.15.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.25.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-vue": "^9.6.0",
|
||||
"husky": "^8.0.1",
|
||||
"less": "^4.1.3",
|
||||
"lint-staged": "^13.0.3",
|
||||
"mockjs": "^1.1.0",
|
||||
"postcss-html": "^1.5.0",
|
||||
"prettier": "^2.7.1",
|
||||
"rollup": "^3.9.1",
|
||||
"rollup-plugin-visualizer": "^5.8.2",
|
||||
"stylelint": "^14.13.0",
|
||||
"stylelint-config-prettier": "^9.0.3",
|
||||
"stylelint-config-rational-order": "^0.1.2",
|
||||
"stylelint-config-recommended-vue": "^1.4.0",
|
||||
"stylelint-config-standard": "^29.0.0",
|
||||
"stylelint-order": "^5.0.0",
|
||||
"typescript": "^4.8.4",
|
||||
"unplugin-vue-components": "^0.24.1",
|
||||
"vite": "^3.2.5",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-svg-loader": "^3.6.0",
|
||||
"vue-tsc": "^1.0.14"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"rollup": "^2.56.3"
|
||||
}
|
||||
}
|
||||
26
frontend/src/App.vue
Normal file
26
frontend/src/App.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<a-config-provider :locale="locale">
|
||||
<router-view />
|
||||
<global-setting />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import enUS from '@arco-design/web-vue/es/locale/lang/en-us';
|
||||
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
|
||||
import GlobalSetting from '@/components/global-setting/index.vue';
|
||||
import useLocale from '@/hooks/locale';
|
||||
|
||||
const { currentLocale } = useLocale();
|
||||
const locale = computed(() => {
|
||||
switch (currentLocale.value) {
|
||||
case 'zh-CN':
|
||||
return zhCN;
|
||||
case 'en-US':
|
||||
return enUS;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
63
frontend/src/api/auth.ts
Normal file
63
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface LoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type UserType = 'system_admin' | 'user';
|
||||
|
||||
export interface LoginRes {
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
loginName?: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
userType: UserType;
|
||||
passwordChanged: boolean;
|
||||
roles: string[];
|
||||
realName?: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
userId: string;
|
||||
username: string;
|
||||
loginName?: string;
|
||||
realName?: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
userType: UserType;
|
||||
passwordChanged: boolean;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
const demoUser: LoginRes = {
|
||||
accessToken: 'template-demo-token',
|
||||
userId: 'template-admin',
|
||||
username: 'admin',
|
||||
realName: '平台管理员',
|
||||
role: 'system_admin',
|
||||
permissions: ['*'],
|
||||
userType: 'system_admin',
|
||||
passwordChanged: true,
|
||||
roles: ['system_admin'],
|
||||
};
|
||||
|
||||
export async function login(data: LoginData) {
|
||||
if (!data.username || !data.password) throw new Error('请输入账号和密码');
|
||||
return { data: { ...demoUser, username: data.username } };
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return { data: null };
|
||||
}
|
||||
|
||||
export async function getUserInfo() {
|
||||
return { data: demoUser as UserInfo };
|
||||
}
|
||||
|
||||
export async function changePassword(_data?: {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}) {
|
||||
return { data: null };
|
||||
}
|
||||
106
frontend/src/api/book.ts
Normal file
106
frontend/src/api/book.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/** book 业务 API — 包 mcp 的 create_book / list_books / get_book / update_book_status / soft_delete_book */
|
||||
import { callTool } from './mcp';
|
||||
|
||||
export interface Book {
|
||||
id: number;
|
||||
name: string;
|
||||
subject?: string;
|
||||
grade?: string;
|
||||
version?: string;
|
||||
type?: string;
|
||||
zip_path: string;
|
||||
zip_sha256: string;
|
||||
zip_size: number;
|
||||
status: 'pending' | 'processing' | 'done' | 'failed' | 'partial';
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
error_msg?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface ListBooksResp {
|
||||
books: Book[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export async function listBooks(params: {
|
||||
status?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
keyword?: string;
|
||||
} = {}): Promise<ListBooksResp> {
|
||||
return callTool<ListBooksResp>('list_books', params);
|
||||
}
|
||||
|
||||
export async function getBook(bookId: number): Promise<Book & {
|
||||
documents: BookDocument[];
|
||||
}> {
|
||||
return callTool('get_book', { book_id: bookId });
|
||||
}
|
||||
|
||||
export async function createBook(params: {
|
||||
name: string;
|
||||
zip_path: string;
|
||||
zip_sha256: string;
|
||||
zip_size?: number;
|
||||
subject?: string;
|
||||
grade?: string;
|
||||
version?: string;
|
||||
type?: string;
|
||||
created_by?: string;
|
||||
}): Promise<{ ok: boolean; book_id: number; status: string; idempotent: boolean }> {
|
||||
return callTool('create_book', params);
|
||||
}
|
||||
|
||||
export async function updateBookStatus(
|
||||
bookId: number,
|
||||
status: string,
|
||||
error_msg?: string,
|
||||
): Promise<{ ok: boolean }> {
|
||||
return callTool('update_book_status', { book_id: bookId, status, error_msg });
|
||||
}
|
||||
|
||||
export async function softDeleteBook(bookId: number): Promise<{ ok: boolean }> {
|
||||
return callTool('soft_delete_book', { book_id: bookId });
|
||||
}
|
||||
|
||||
export async function retryFailed(bookId: number): Promise<{
|
||||
ok: boolean;
|
||||
retried_count: number;
|
||||
}> {
|
||||
return callTool('retry_failed', { book_id: bookId });
|
||||
}
|
||||
|
||||
// ----- 嵌套类型 -----
|
||||
export interface BookDocument {
|
||||
book_id: number;
|
||||
doc_id: string;
|
||||
original_filename: string;
|
||||
original_path: string;
|
||||
title?: string;
|
||||
doc_type: string;
|
||||
order_index: number;
|
||||
status: 'pending' | 'html_ready' | 'json_ready' | 'failed';
|
||||
error_msg?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
files?: BookFile[];
|
||||
}
|
||||
|
||||
export interface BookFile {
|
||||
id: number;
|
||||
doc_id: string;
|
||||
file_type: 'docx' | 'html' | 'json' | 'preview_png' | 'error_log';
|
||||
object_key: string;
|
||||
object_bucket: string;
|
||||
size: number;
|
||||
checksum?: string;
|
||||
url?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
}
|
||||
92
frontend/src/api/document.ts
Normal file
92
frontend/src/api/document.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/** document / file 业务 API */
|
||||
import { callTool } from './mcp';
|
||||
import type { BookDocument, BookFile } from './book';
|
||||
|
||||
export type { BookDocument, BookFile };
|
||||
|
||||
export async function addDocument(params: {
|
||||
book_id: number;
|
||||
original_filename: string;
|
||||
original_path: string;
|
||||
title?: string;
|
||||
doc_type?: string;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
doc_id: string;
|
||||
order_index: number;
|
||||
status: string;
|
||||
doc_type: string;
|
||||
idempotent: boolean;
|
||||
}> {
|
||||
return callTool('add_document', params);
|
||||
}
|
||||
|
||||
export async function getDocument(docId: string): Promise<BookDocument & {
|
||||
files: BookFile[];
|
||||
}> {
|
||||
return callTool('get_document', { doc_id: docId });
|
||||
}
|
||||
|
||||
export async function listDocuments(params: {
|
||||
book_id: number;
|
||||
status?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<{
|
||||
documents: BookDocument[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}> {
|
||||
return callTool('list_documents', params);
|
||||
}
|
||||
|
||||
export async function markHtmlReady(params: {
|
||||
doc_id: string;
|
||||
html_url: string;
|
||||
html_size: number;
|
||||
formulas?: number;
|
||||
images?: number;
|
||||
chars?: number;
|
||||
}): Promise<{ ok: boolean; file_id: number; url: string }> {
|
||||
return callTool('mark_html_ready', params);
|
||||
}
|
||||
|
||||
export async function markJsonReady(params: {
|
||||
doc_id: string;
|
||||
json_url: string;
|
||||
json_size: number;
|
||||
schema_version?: string;
|
||||
}): Promise<{ ok: boolean; file_id: number; url: string }> {
|
||||
return callTool('mark_json_ready', params);
|
||||
}
|
||||
|
||||
export async function markFailed(params: {
|
||||
doc_id: string;
|
||||
error_msg: string;
|
||||
stage?: 'html' | 'json' | 'upload';
|
||||
}): Promise<{ ok: boolean }> {
|
||||
return callTool('mark_failed', params);
|
||||
}
|
||||
|
||||
export async function getDocumentUrl(
|
||||
doc_id: string,
|
||||
file_type: 'html' | 'json' | 'docx' | 'error_log',
|
||||
): Promise<{ file_type: string; url: string; size: number; checksum?: string } | null> {
|
||||
return callTool('get_document_url', { doc_id, file_type });
|
||||
}
|
||||
|
||||
export async function uploadToRustfs(params: {
|
||||
local_path: string;
|
||||
key: string;
|
||||
bucket?: string;
|
||||
}): Promise<{ url: string; key: string; bucket: string; size: number; checksum: string }> {
|
||||
return callTool('upload_to_rustfs', params);
|
||||
}
|
||||
|
||||
export async function health(): Promise<{
|
||||
wordparse: boolean;
|
||||
mongo: boolean;
|
||||
}> {
|
||||
return callTool('health', {});
|
||||
}
|
||||
7
frontend/src/api/interceptor.ts
Normal file
7
frontend/src/api/interceptor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface HttpResponse<T = unknown> {
|
||||
status?: string;
|
||||
code?: number;
|
||||
data: T;
|
||||
msg?: string;
|
||||
message?: string;
|
||||
}
|
||||
82
frontend/src/api/mcp.ts
Normal file
82
frontend/src/api/mcp.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* MCP JSON-RPC 客户端 — 调 book-mcp 的工具
|
||||
*
|
||||
* fastmcp 走 JSON-RPC 2.0,POST /mcp,body:
|
||||
* {"jsonrpc":"2.0", "id":1, "method":"tools/call", "params":{"name":"...","arguments":{...}}}
|
||||
*
|
||||
* 成功:{"jsonrpc":"2.0","id":1,"result":{"content":[...], "data": <实际返回值>}}
|
||||
* 失败:{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"..."}}
|
||||
*/
|
||||
import axios from 'axios';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
|
||||
export interface McpCallResult<T = unknown> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8100/mcp',
|
||||
timeout: 30000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// 拦截器:错误时弹 Arco Message
|
||||
http.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(err) => {
|
||||
const msg = err?.response?.data?.error?.message
|
||||
|| err?.response?.data?.message
|
||||
|| err.message
|
||||
|| 'unknown error';
|
||||
Message.error(`MCP 错误:${msg}`);
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
let _id = 0;
|
||||
function nextId() {
|
||||
_id += 1;
|
||||
return _id;
|
||||
}
|
||||
|
||||
/** 调 MCP 工具 */
|
||||
export async function callTool<T = unknown>(
|
||||
name: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const { data } = await http.post('', {
|
||||
jsonrpc: '2.0',
|
||||
id: nextId(),
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args },
|
||||
});
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message || JSON.stringify(data.error));
|
||||
}
|
||||
// fastmcp 返回结构:{ result: { content: [...], structuredContent: <actual>, isError } }
|
||||
const result = data.result;
|
||||
if (result?.isError) {
|
||||
const errText = result.content?.[0]?.text || 'tool error';
|
||||
throw new Error(errText);
|
||||
}
|
||||
// 优先 structuredContent(fastmcp 4.0 标准),fallback 到 data 字段
|
||||
return (result?.structuredContent ?? result?.data ?? result) as T;
|
||||
}
|
||||
|
||||
/** 列所有工具(调试用) */
|
||||
export async function listTools(): Promise<McpTool[]> {
|
||||
const { data } = await http.post('', {
|
||||
jsonrpc: '2.0',
|
||||
id: nextId(),
|
||||
method: 'tools/list',
|
||||
});
|
||||
return data.result?.tools || [];
|
||||
}
|
||||
|
||||
export default http;
|
||||
23
frontend/src/api/message.ts
Normal file
23
frontend/src/api/message.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface MessageRecord {
|
||||
id: number;
|
||||
type: 'message' | 'notice' | 'todo';
|
||||
title: string;
|
||||
subTitle: string;
|
||||
content: string;
|
||||
time: string;
|
||||
status: 0 | 1;
|
||||
avatar?: string;
|
||||
messageType?: number;
|
||||
}
|
||||
|
||||
export type MessageListType = MessageRecord[];
|
||||
|
||||
const messages: MessageRecord[] = [];
|
||||
|
||||
export async function queryMessageList() {
|
||||
return { data: messages };
|
||||
}
|
||||
|
||||
export async function setMessageStatus(_params?: { ids: number[] }) {
|
||||
return { data: messages };
|
||||
}
|
||||
7
frontend/src/api/user.ts
Normal file
7
frontend/src/api/user.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface MenuListResponse {
|
||||
data: unknown[];
|
||||
}
|
||||
|
||||
export async function getMenuList(): Promise<MenuListResponse> {
|
||||
return { data: [] };
|
||||
}
|
||||
BIN
frontend/src/assets/images/login-banner.png
Normal file
BIN
frontend/src/assets/images/login-banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
12
frontend/src/assets/logo.svg
Normal file
12
frontend/src/assets/logo.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
19
frontend/src/assets/style/breakpoint.less
Normal file
19
frontend/src/assets/style/breakpoint.less
Normal file
@@ -0,0 +1,19 @@
|
||||
// ==============breakpoint============
|
||||
|
||||
// Extra small screen / phone
|
||||
@screen-xs: 480px;
|
||||
|
||||
// Small screen / tablet
|
||||
@screen-sm: 576px;
|
||||
|
||||
// Medium screen / desktop
|
||||
@screen-md: 768px;
|
||||
|
||||
// Large screen / wide desktop
|
||||
@screen-lg: 992px;
|
||||
|
||||
// Extra large screen / full hd
|
||||
@screen-xl: 1200px;
|
||||
|
||||
// Extra extra large screen / large desktop
|
||||
@screen-xxl: 1600px;
|
||||
94
frontend/src/assets/style/global.less
Normal file
94
frontend/src/assets/style/global.less
Normal file
@@ -0,0 +1,94 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
background-color: var(--color-bg-1);
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.echarts-tooltip-diy {
|
||||
background: linear-gradient(
|
||||
304.17deg,
|
||||
rgba(253, 254, 255, 0.6) -6.04%,
|
||||
rgba(244, 247, 252, 0.6) 85.2%
|
||||
) !important;
|
||||
border: none !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
/* Note: backdrop-filter has minimal browser support */
|
||||
|
||||
border-radius: 6px !important;
|
||||
.content-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 9px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
width: 164px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
box-shadow: 6px 0px 20px rgba(34, 87, 188, 0.1);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tooltip-title {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
.tooltip-title,
|
||||
.tooltip-value {
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: right;
|
||||
color: #1d2129;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tooltip-item-icon {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.general-card {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
& > .arco-card-header {
|
||||
height: auto;
|
||||
padding: 20px;
|
||||
border: none;
|
||||
}
|
||||
& > .arco-card-body {
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.split-line {
|
||||
border-color: rgb(var(--gray-2));
|
||||
}
|
||||
|
||||
.arco-table-cell {
|
||||
.circle {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(var(--blue-6));
|
||||
&.pass {
|
||||
background-color: rgb(var(--green-6));
|
||||
}
|
||||
}
|
||||
}
|
||||
35
frontend/src/components/breadcrumb/index.vue
Normal file
35
frontend/src/components/breadcrumb/index.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<a-breadcrumb class="container-breadcrumb">
|
||||
<a-breadcrumb-item>
|
||||
<icon-apps />
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item v-for="item in items" :key="item">
|
||||
{{ $t(item) }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array as PropType<string[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container-breadcrumb {
|
||||
margin: 16px 0;
|
||||
:deep(.arco-breadcrumb-item) {
|
||||
color: rgb(var(--gray-6));
|
||||
&:last-child {
|
||||
color: rgb(var(--gray-8));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
frontend/src/components/chart/index.vue
Normal file
47
frontend/src/components/chart/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<VCharts
|
||||
v-if="renderChart"
|
||||
:option="options"
|
||||
:autoresize="autoResize"
|
||||
:style="{ width, height }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, nextTick } from 'vue';
|
||||
import VCharts from 'vue-echarts';
|
||||
// import { useAppStore } from '@/store';
|
||||
|
||||
defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
autoResize: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
});
|
||||
// const appStore = useAppStore();
|
||||
// const theme = computed(() => {
|
||||
// if (appStore.theme === 'dark') return 'dark';
|
||||
// return '';
|
||||
// });
|
||||
const renderChart = ref(false);
|
||||
// wait container expand
|
||||
nextTick(() => {
|
||||
renderChart.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
28
frontend/src/components/coming-soon/index.vue
Normal file
28
frontend/src/components/coming-soon/index.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="coming-soon-container">
|
||||
<a-result
|
||||
status="404"
|
||||
:subtitle="message || '该功能正在开发中,敬请期待...'"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-tool style="font-size: 64px; color: #c9cdd4;" />
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
message?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.coming-soon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
55
frontend/src/components/floating-actions/index.vue
Normal file
55
frontend/src/components/floating-actions/index.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="floating-actions">
|
||||
<a-tooltip content="常见问题" position="left">
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
class="action-btn"
|
||||
@click="handleFAQ"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-question-circle />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { openWindow } from '@/utils';
|
||||
|
||||
const handleFAQ = () => {
|
||||
// 打开常见问题页面(外部链接)
|
||||
openWindow('https://arco.design/vue/docs/pro/faq');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.floating-actions {
|
||||
position: fixed;
|
||||
bottom: 200px;
|
||||
right: 0;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-right: 0;
|
||||
|
||||
.action-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 24px 0 0 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
16
frontend/src/components/footer/index.vue
Normal file
16
frontend/src/components/footer/index.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<a-layout-footer class="footer">DoLuEdu</a-layout-footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
color: var(--color-text-2);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
79
frontend/src/components/global-setting/block.vue
Normal file
79
frontend/src/components/global-setting/block.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="block">
|
||||
<h5 class="title">{{ title }}</h5>
|
||||
<div v-for="option in options" :key="option.name" class="switch-wrapper">
|
||||
<span>{{ $t(option.name) }}</span>
|
||||
<form-wrapper
|
||||
:type="option.type || 'switch'"
|
||||
:name="option.key"
|
||||
:default-value="option.defaultVal"
|
||||
@input-change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
import FormWrapper from './form-wrapper.vue';
|
||||
|
||||
interface OptionsProps {
|
||||
name: string;
|
||||
key: string;
|
||||
type?: string;
|
||||
defaultVal?: boolean | string | number;
|
||||
}
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array as PropType<OptionsProps[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
const appStore = useAppStore();
|
||||
const handleChange = async ({
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}) => {
|
||||
if (key === 'colorWeak') {
|
||||
document.body.style.filter = value ? 'invert(80%)' : 'none';
|
||||
}
|
||||
if (key === 'menuFromServer' && value) {
|
||||
await appStore.fetchServerMenuConfig();
|
||||
}
|
||||
if (key === 'topMenu') {
|
||||
appStore.updateSettings({
|
||||
menuCollapse: false,
|
||||
});
|
||||
}
|
||||
appStore.updateSettings({ [key]: value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.block {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 10px 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.switch-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
}
|
||||
</style>
|
||||
39
frontend/src/components/global-setting/form-wrapper.vue
Normal file
39
frontend/src/components/global-setting/form-wrapper.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<a-input-number
|
||||
v-if="type === 'number'"
|
||||
:style="{ width: '80px' }"
|
||||
size="small"
|
||||
:default-value="(defaultValue as number)"
|
||||
@change="handleChange"
|
||||
/>
|
||||
<a-switch
|
||||
v-else
|
||||
:default-checked="(defaultValue as boolean)"
|
||||
size="small"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
defaultValue: {
|
||||
type: [String, Boolean, Number],
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['inputChange']);
|
||||
const handleChange = (value: unknown) => {
|
||||
emit('inputChange', {
|
||||
value,
|
||||
key: props.name,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
98
frontend/src/components/global-setting/index.vue
Normal file
98
frontend/src/components/global-setting/index.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div v-if="!appStore.navbar" class="fixed-settings" @click="setVisible">
|
||||
<a-button type="primary">
|
||||
<template #icon>
|
||||
<icon-settings />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
<a-drawer
|
||||
:width="300"
|
||||
unmount-on-close
|
||||
:visible="visible"
|
||||
:cancel-text="$t('settings.close')"
|
||||
:ok-text="$t('settings.copySettings')"
|
||||
@ok="copySettings"
|
||||
@cancel="cancel"
|
||||
>
|
||||
<template #title> {{ $t('settings.title') }} </template>
|
||||
<Block :options="contentOpts" :title="$t('settings.content')" />
|
||||
<Block :options="othersOpts" :title="$t('settings.otherSettings')" />
|
||||
<a-alert>{{ $t('settings.alertContent') }}</a-alert>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { useAppStore } from '@/store';
|
||||
import Block from './block.vue';
|
||||
|
||||
const emit = defineEmits(['cancel']);
|
||||
|
||||
const appStore = useAppStore();
|
||||
const { t } = useI18n();
|
||||
const { copy } = useClipboard();
|
||||
const visible = computed(() => appStore.globalSettings);
|
||||
const contentOpts = computed(() => [
|
||||
{ name: 'settings.navbar', key: 'navbar', defaultVal: appStore.navbar },
|
||||
{
|
||||
name: 'settings.menu',
|
||||
key: 'menu',
|
||||
defaultVal: appStore.menu,
|
||||
},
|
||||
{
|
||||
name: 'settings.topMenu',
|
||||
key: 'topMenu',
|
||||
defaultVal: appStore.topMenu,
|
||||
},
|
||||
{ name: 'settings.footer', key: 'footer', defaultVal: appStore.footer },
|
||||
{ name: 'settings.tabBar', key: 'tabBar', defaultVal: appStore.tabBar },
|
||||
{
|
||||
name: 'settings.menuFromServer',
|
||||
key: 'menuFromServer',
|
||||
defaultVal: appStore.menuFromServer,
|
||||
},
|
||||
{
|
||||
name: 'settings.menuWidth',
|
||||
key: 'menuWidth',
|
||||
defaultVal: appStore.menuWidth,
|
||||
type: 'number',
|
||||
},
|
||||
]);
|
||||
const othersOpts = computed(() => [
|
||||
{
|
||||
name: 'settings.colorWeak',
|
||||
key: 'colorWeak',
|
||||
defaultVal: appStore.colorWeak,
|
||||
},
|
||||
]);
|
||||
|
||||
const cancel = () => {
|
||||
appStore.updateSettings({ globalSettings: false });
|
||||
emit('cancel');
|
||||
};
|
||||
const copySettings = async () => {
|
||||
const text = JSON.stringify(appStore.$state, null, 2);
|
||||
await copy(text);
|
||||
Message.success(t('settings.copySettings.message'));
|
||||
};
|
||||
const setVisible = () => {
|
||||
appStore.updateSettings({ globalSettings: true });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.fixed-settings {
|
||||
position: fixed;
|
||||
top: 280px;
|
||||
right: 0;
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
vertical-align: -4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
35
frontend/src/components/index.ts
Normal file
35
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { App } from 'vue';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
} from 'echarts/components';
|
||||
import Chart from './chart/index.vue';
|
||||
import Breadcrumb from './breadcrumb/index.vue';
|
||||
|
||||
// Manually introduce ECharts modules to reduce packing size
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
]);
|
||||
|
||||
export default {
|
||||
install(Vue: App) {
|
||||
Vue.component('Chart', Chart);
|
||||
Vue.component('Breadcrumb', Breadcrumb);
|
||||
},
|
||||
};
|
||||
161
frontend/src/components/menu/index.vue
Normal file
161
frontend/src/components/menu/index.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, ref, h, compile, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter, RouteRecordRaw } from 'vue-router';
|
||||
import type { RouteMeta } from 'vue-router';
|
||||
import { useAppStore } from '@/store';
|
||||
import { listenerRouteChange } from '@/utils/route-listener';
|
||||
import { openWindow, regexUrl } from '@/utils';
|
||||
import useMenuTree from './use-menu-tree';
|
||||
|
||||
export default defineComponent({
|
||||
emit: ['collapse'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { menuTree } = useMenuTree();
|
||||
const collapsed = computed({
|
||||
get() {
|
||||
if (appStore.device === 'desktop') return appStore.menuCollapse;
|
||||
return false;
|
||||
},
|
||||
set(value: boolean) {
|
||||
appStore.updateSettings({ menuCollapse: value });
|
||||
},
|
||||
});
|
||||
|
||||
const topMenu = computed(() => appStore.topMenu);
|
||||
// 默认打开仪表盘菜单
|
||||
const openKeys = ref<string[]>(['dashboard']);
|
||||
const selectedKey = ref<string[]>([]);
|
||||
|
||||
const goto = (item: RouteRecordRaw) => {
|
||||
// Open external link
|
||||
if (regexUrl.test(item.path)) {
|
||||
openWindow(item.path);
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// Eliminate external link side effects
|
||||
const { hideInMenu, activeMenu } = item.meta as RouteMeta;
|
||||
if (route.name === item.name && !hideInMenu && !activeMenu) {
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// Trigger router change
|
||||
router.push({
|
||||
name: item.name,
|
||||
});
|
||||
};
|
||||
const findMenuOpenKeys = (target: string) => {
|
||||
const result: string[] = [];
|
||||
let isFind = false;
|
||||
const backtrack = (item: RouteRecordRaw, keys: string[]) => {
|
||||
if (item.name === target) {
|
||||
isFind = true;
|
||||
result.push(...keys);
|
||||
return;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
item.children.forEach((el) => {
|
||||
backtrack(el, [...keys, el.name as string]);
|
||||
});
|
||||
}
|
||||
};
|
||||
menuTree.value.forEach((el: RouteRecordRaw) => {
|
||||
if (isFind) return; // Performance optimization
|
||||
backtrack(el, [el.name as string]);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
listenerRouteChange((newRoute) => {
|
||||
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
|
||||
if (requiresAuth && (!hideInMenu || activeMenu)) {
|
||||
const menuOpenKeys = findMenuOpenKeys(
|
||||
(activeMenu || newRoute.name) as string
|
||||
);
|
||||
|
||||
const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
|
||||
openKeys.value = [...keySet];
|
||||
|
||||
selectedKey.value = [
|
||||
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
|
||||
];
|
||||
}
|
||||
}, true);
|
||||
const setCollapse = (val: boolean) => {
|
||||
if (appStore.device === 'desktop')
|
||||
appStore.updateSettings({ menuCollapse: val });
|
||||
};
|
||||
|
||||
const renderSubMenu = () => {
|
||||
function travel(_route: RouteRecordRaw[], nodes = []) {
|
||||
if (_route) {
|
||||
_route.forEach((element) => {
|
||||
// This is demo, modify nodes as needed
|
||||
const icon = element?.meta?.icon
|
||||
? () => h(compile(`<${element?.meta?.icon}/>`))
|
||||
: null;
|
||||
const node =
|
||||
element?.children && element?.children.length !== 0 ? (
|
||||
<a-sub-menu
|
||||
key={element?.name}
|
||||
v-slots={{
|
||||
icon,
|
||||
title: () => h(compile(t(element?.meta?.locale || ''))),
|
||||
}}
|
||||
>
|
||||
{travel(element?.children)}
|
||||
</a-sub-menu>
|
||||
) : (
|
||||
<a-menu-item
|
||||
key={element?.name}
|
||||
v-slots={{ icon }}
|
||||
onClick={() => goto(element)}
|
||||
>
|
||||
{t(element?.meta?.locale || '')}
|
||||
</a-menu-item>
|
||||
);
|
||||
nodes.push(node as never);
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
return travel(menuTree.value);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<a-menu
|
||||
mode={topMenu.value ? 'horizontal' : 'vertical'}
|
||||
v-model:collapsed={collapsed.value}
|
||||
v-model:open-keys={openKeys.value}
|
||||
show-collapse-button={appStore.device !== 'mobile'}
|
||||
auto-open={false}
|
||||
selected-keys={selectedKey.value}
|
||||
auto-open-selected={true}
|
||||
level-indent={34}
|
||||
style="height: 100%;width:100%;"
|
||||
onCollapse={setCollapse}
|
||||
>
|
||||
{renderSubMenu()}
|
||||
</a-menu>
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.arco-menu-inner) {
|
||||
.arco-menu-inline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.arco-icon {
|
||||
&:not(.arco-icon-down) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
77
frontend/src/components/menu/use-menu-tree.ts
Normal file
77
frontend/src/components/menu/use-menu-tree.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { computed } from 'vue';
|
||||
import { RouteRecordRaw, RouteRecordNormalized } from 'vue-router';
|
||||
import usePermission from '@/hooks/permission';
|
||||
import { useAppStore } from '@/store';
|
||||
import appClientMenus from '@/router/app-menus';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
export default function useMenuTree() {
|
||||
const permission = usePermission();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const appRoute = computed(() => {
|
||||
if (appStore.menuFromServer) {
|
||||
return appStore.appAsyncMenus;
|
||||
}
|
||||
return appClientMenus;
|
||||
});
|
||||
|
||||
const menuTree = computed(() => {
|
||||
const copyRouter = cloneDeep(appRoute.value) as RouteRecordNormalized[];
|
||||
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
|
||||
return (a.meta.order || 0) - (b.meta.order || 0);
|
||||
});
|
||||
|
||||
function travel(_routes: RouteRecordRaw[], layer: number) {
|
||||
if (!_routes) return null;
|
||||
|
||||
const collector: any = _routes.map((element) => {
|
||||
// no access
|
||||
if (!permission.accessRouter(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// hide parent menu if hideInMenu is true
|
||||
if (element.meta?.hideInMenu === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// leaf node
|
||||
if (element.meta?.hideChildrenInMenu || !element.children) {
|
||||
element.children = [];
|
||||
return element;
|
||||
}
|
||||
|
||||
// route filter hideInMenu true
|
||||
element.children = element.children.filter(
|
||||
(x) => x.meta?.hideInMenu !== true
|
||||
);
|
||||
|
||||
// Associated child node
|
||||
const subItem = travel(element.children, layer + 1);
|
||||
|
||||
if (subItem.length) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
// the else logic
|
||||
if (layer > 1) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
|
||||
if (element.meta?.hideInMenu === false) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
return collector.filter(Boolean);
|
||||
}
|
||||
return travel(copyRouter, 0);
|
||||
});
|
||||
|
||||
return {
|
||||
menuTree,
|
||||
};
|
||||
}
|
||||
129
frontend/src/components/message-box/index.vue
Normal file
129
frontend/src/components/message-box/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<a-spin style="display: block" :loading="loading">
|
||||
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
|
||||
<a-tab-pane v-for="item in tabList" :key="item.key">
|
||||
<template #title>
|
||||
<span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span>
|
||||
</template>
|
||||
<a-result v-if="!renderList.length" status="404">
|
||||
<template #subtitle> {{ $t('messageBox.noContent') }} </template>
|
||||
</a-result>
|
||||
<List
|
||||
:render-list="renderList"
|
||||
:unread-count="unreadCount"
|
||||
@item-click="handleItemClick"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<template #extra>
|
||||
<a-button type="text" @click="emptyList">
|
||||
{{ $t('messageBox.tab.button') }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, toRefs, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
queryMessageList,
|
||||
setMessageStatus,
|
||||
MessageRecord,
|
||||
MessageListType,
|
||||
} from '@/api/message';
|
||||
import useLoading from '@/hooks/loading';
|
||||
import List from './list.vue';
|
||||
|
||||
interface TabItem {
|
||||
key: string;
|
||||
title: string;
|
||||
avatar?: string;
|
||||
}
|
||||
const { loading, setLoading } = useLoading(true);
|
||||
const messageType = ref('message');
|
||||
const { t } = useI18n();
|
||||
const messageData = reactive<{
|
||||
renderList: MessageRecord[];
|
||||
messageList: MessageRecord[];
|
||||
}>({
|
||||
renderList: [],
|
||||
messageList: [],
|
||||
});
|
||||
toRefs(messageData);
|
||||
const tabList: TabItem[] = [
|
||||
{
|
||||
key: 'message',
|
||||
title: t('messageBox.tab.title.message'),
|
||||
},
|
||||
{
|
||||
key: 'notice',
|
||||
title: t('messageBox.tab.title.notice'),
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
title: t('messageBox.tab.title.todo'),
|
||||
},
|
||||
];
|
||||
async function fetchSourceData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await queryMessageList();
|
||||
messageData.messageList = data;
|
||||
} catch (err) {
|
||||
// you can report use errorHandler or other
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function readMessage(data: MessageListType) {
|
||||
const ids = data.map((item) => item.id);
|
||||
await setMessageStatus({ ids });
|
||||
fetchSourceData();
|
||||
}
|
||||
const renderList = computed(() => {
|
||||
return messageData.messageList.filter(
|
||||
(item) => messageType.value === item.type
|
||||
);
|
||||
});
|
||||
const unreadCount = computed(() => {
|
||||
return renderList.value.filter((item) => !item.status).length;
|
||||
});
|
||||
const getUnreadList = (type: string) => {
|
||||
const list = messageData.messageList.filter(
|
||||
(item) => item.type === type && !item.status
|
||||
);
|
||||
return list;
|
||||
};
|
||||
const formatUnreadLength = (type: string) => {
|
||||
const list = getUnreadList(type);
|
||||
return list.length ? `(${list.length})` : ``;
|
||||
};
|
||||
const handleItemClick = (items: MessageListType) => {
|
||||
if (renderList.value.length) readMessage([...items]);
|
||||
};
|
||||
const emptyList = () => {
|
||||
messageData.messageList = [];
|
||||
};
|
||||
fetchSourceData();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-popover-popup-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.arco-list-item-meta) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
:deep(.arco-tabs-nav) {
|
||||
padding: 14px 0 12px 16px;
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
:deep(.arco-tabs-content) {
|
||||
padding-top: 0;
|
||||
.arco-result-subtitle {
|
||||
color: rgb(var(--gray-6));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
149
frontend/src/components/message-box/list.vue
Normal file
149
frontend/src/components/message-box/list.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<a-list :bordered="false">
|
||||
<a-list-item
|
||||
v-for="item in renderList"
|
||||
:key="item.id"
|
||||
action-layout="vertical"
|
||||
:style="{
|
||||
opacity: item.status ? 0.5 : 1,
|
||||
}"
|
||||
>
|
||||
<template #extra>
|
||||
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
|
||||
</template>
|
||||
<div class="item-wrap" @click="onItemClick(item)">
|
||||
<a-list-item-meta>
|
||||
<template v-if="item.avatar" #avatar>
|
||||
<a-avatar shape="circle">
|
||||
<img v-if="item.avatar" :src="item.avatar" />
|
||||
<icon-desktop v-else />
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template #title>
|
||||
<a-space :size="4">
|
||||
<span>{{ item.title }}</span>
|
||||
<a-typography-text type="secondary">
|
||||
{{ item.subTitle }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #description>
|
||||
<div>
|
||||
<a-typography-paragraph
|
||||
:ellipsis="{
|
||||
rows: 1,
|
||||
}"
|
||||
>{{ item.content }}</a-typography-paragraph
|
||||
>
|
||||
<a-typography-text
|
||||
v-if="item.type === 'message'"
|
||||
class="time-text"
|
||||
>
|
||||
{{ item.time }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</div>
|
||||
</a-list-item>
|
||||
<template #footer>
|
||||
<a-space
|
||||
fill
|
||||
:size="0"
|
||||
:class="{ 'add-border-top': renderList.length < showMax }"
|
||||
>
|
||||
<div class="footer-wrap">
|
||||
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link>
|
||||
</div>
|
||||
<div class="footer-wrap">
|
||||
<a-link>{{ $t('messageBox.viewMore') }}</a-link>
|
||||
</div>
|
||||
</a-space>
|
||||
</template>
|
||||
<div
|
||||
v-if="renderList.length && renderList.length < 3"
|
||||
:style="{ height: (showMax - renderList.length) * 86 + 'px' }"
|
||||
></div>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue';
|
||||
import { MessageRecord, MessageListType } from '@/api/message';
|
||||
|
||||
const props = defineProps({
|
||||
renderList: {
|
||||
type: Array as PropType<MessageListType>,
|
||||
required: true,
|
||||
},
|
||||
unreadCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['itemClick']);
|
||||
const allRead = () => {
|
||||
emit('itemClick', [...props.renderList]);
|
||||
};
|
||||
|
||||
const onItemClick = (item: MessageRecord) => {
|
||||
if (!item.status) {
|
||||
emit('itemClick', [item]);
|
||||
}
|
||||
};
|
||||
const showMax = 3;
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-list) {
|
||||
.arco-list-item {
|
||||
min-height: 86px;
|
||||
border-bottom: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
.arco-list-item-extra {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
.arco-list-item-meta-content {
|
||||
flex: 1;
|
||||
}
|
||||
.item-wrap {
|
||||
cursor: pointer;
|
||||
}
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--gray-6));
|
||||
}
|
||||
.arco-empty {
|
||||
display: none;
|
||||
}
|
||||
.arco-list-footer {
|
||||
padding: 0;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
border-top: none;
|
||||
.arco-space-item {
|
||||
width: 100%;
|
||||
border-right: 1px solid rgb(var(--gray-3));
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
.add-border-top {
|
||||
border-top: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
}
|
||||
.footer-wrap {
|
||||
text-align: center;
|
||||
}
|
||||
.arco-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.add-border {
|
||||
border-top: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
13
frontend/src/components/message-box/locale/en-US.ts
Normal file
13
frontend/src/components/message-box/locale/en-US.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
'messageBox.tab.title.message': 'Message',
|
||||
'messageBox.tab.title.notice': 'Notice',
|
||||
'messageBox.tab.title.todo': 'Todo',
|
||||
'messageBox.tab.button': 'empty',
|
||||
'messageBox.allRead': 'All Read',
|
||||
'messageBox.viewMore': 'View More',
|
||||
'messageBox.noContent': 'No Content',
|
||||
'messageBox.switchRoles': 'Switch Roles',
|
||||
'messageBox.userCenter': 'User Center',
|
||||
'messageBox.userSettings': 'User Settings',
|
||||
'messageBox.logout': 'Logout',
|
||||
};
|
||||
13
frontend/src/components/message-box/locale/zh-CN.ts
Normal file
13
frontend/src/components/message-box/locale/zh-CN.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
'messageBox.tab.title.message': '消息',
|
||||
'messageBox.tab.title.notice': '通知',
|
||||
'messageBox.tab.title.todo': '待办',
|
||||
'messageBox.tab.button': '清空',
|
||||
'messageBox.allRead': '全部已读',
|
||||
'messageBox.viewMore': '查看更多',
|
||||
'messageBox.noContent': '暂无内容',
|
||||
'messageBox.switchRoles': '切换角色',
|
||||
'messageBox.userCenter': '用户中心',
|
||||
'messageBox.userSettings': '用户设置',
|
||||
'messageBox.logout': '登出登录',
|
||||
};
|
||||
323
frontend/src/components/navbar/index.vue
Normal file
323
frontend/src/components/navbar/index.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<div class="left-side">
|
||||
<a-space>
|
||||
<img
|
||||
alt="logo"
|
||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image"
|
||||
/>
|
||||
<a-typography-title
|
||||
:style="{ margin: 0, fontSize: '18px' }"
|
||||
:heading="5"
|
||||
>
|
||||
DoLuEdu Template
|
||||
</a-typography-title>
|
||||
<icon-menu-fold
|
||||
v-if="!topMenu && appStore.device === 'mobile'"
|
||||
style="font-size: 22px; cursor: pointer"
|
||||
@click="toggleDrawerMenu"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="center-side">
|
||||
<Menu v-if="topMenu" />
|
||||
</div>
|
||||
<ul class="right-side">
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.search')">
|
||||
<a-button class="nav-btn" type="outline" :shape="'circle'">
|
||||
<template #icon>
|
||||
<icon-search />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.language')">
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="setDropDownVisible"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-language />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-dropdown trigger="click" @select="changeLocale as any">
|
||||
<div ref="triggerBtn" class="trigger-btn"></div>
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-for="item in locales"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-check v-show="item.value === currentLocale" />
|
||||
</template>
|
||||
{{ item.label }}
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip
|
||||
:content="
|
||||
theme === 'light'
|
||||
? $t('settings.navbar.theme.toDark')
|
||||
: $t('settings.navbar.theme.toLight')
|
||||
"
|
||||
>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="handleToggleTheme"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-moon-fill v-if="theme === 'dark'" />
|
||||
<icon-sun-fill v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.navbar.alerts')">
|
||||
<div class="message-box-trigger">
|
||||
<a-badge :count="9" dot>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="setPopoverVisible"
|
||||
>
|
||||
<icon-notification />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-popover
|
||||
trigger="click"
|
||||
:arrow-style="{ display: 'none' }"
|
||||
:content-style="{ padding: 0, minWidth: '400px' }"
|
||||
content-class="message-popover"
|
||||
>
|
||||
<div ref="refBtn" class="ref-btn"></div>
|
||||
<template #content>
|
||||
<message-box />
|
||||
</template>
|
||||
</a-popover>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip
|
||||
:content="
|
||||
isFullscreen
|
||||
? $t('settings.navbar.screen.toExit')
|
||||
: $t('settings.navbar.screen.toFull')
|
||||
"
|
||||
>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="toggleFullScreen"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-fullscreen-exit v-if="isFullscreen" />
|
||||
<icon-fullscreen v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.title')">
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="setVisible"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-settings />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-dropdown trigger="click">
|
||||
<div class="user-info-wrapper">
|
||||
<a-avatar
|
||||
:size="32"
|
||||
:style="{ cursor: 'pointer', backgroundColor: 'rgb(var(--primary-6))' }"
|
||||
>
|
||||
<icon-user />
|
||||
</a-avatar>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ userStore.name || '用户' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #content>
|
||||
<a-doption>
|
||||
<a-space @click="handleLogout">
|
||||
<icon-export />
|
||||
<span>
|
||||
{{ $t('messageBox.logout') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, inject } from 'vue';
|
||||
import { useDark, useToggle, useFullscreen } from '@vueuse/core';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
import { LOCALE_OPTIONS } from '@/locale';
|
||||
import useLocale from '@/hooks/locale';
|
||||
import useUser from '@/hooks/user';
|
||||
import Menu from '@/components/menu/index.vue';
|
||||
import MessageBox from '../message-box/index.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const { logout } = useUser();
|
||||
const { changeLocale, currentLocale } = useLocale();
|
||||
const { isFullscreen, toggle: toggleFullScreen } = useFullscreen();
|
||||
const locales = [...LOCALE_OPTIONS];
|
||||
const theme = computed(() => {
|
||||
return appStore.theme;
|
||||
});
|
||||
const topMenu = computed(() => appStore.topMenu && appStore.menu);
|
||||
const isDark = useDark({
|
||||
selector: 'body',
|
||||
attribute: 'arco-theme',
|
||||
valueDark: 'dark',
|
||||
valueLight: 'light',
|
||||
storageKey: 'arco-theme',
|
||||
onChanged(dark: boolean) {
|
||||
// overridden default behavior
|
||||
appStore.toggleTheme(dark);
|
||||
},
|
||||
});
|
||||
const toggleTheme = useToggle(isDark);
|
||||
const handleToggleTheme = () => {
|
||||
toggleTheme();
|
||||
};
|
||||
const setVisible = () => {
|
||||
appStore.updateSettings({ globalSettings: true });
|
||||
};
|
||||
const refBtn = ref();
|
||||
const triggerBtn = ref();
|
||||
const setPopoverVisible = () => {
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
refBtn.value.dispatchEvent(event);
|
||||
};
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
const setDropDownVisible = () => {
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
triggerBtn.value.dispatchEvent(event);
|
||||
};
|
||||
const toggleDrawerMenu = inject('toggleDrawerMenu') as () => void;
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.left-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.center-side {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.right-side {
|
||||
display: flex;
|
||||
padding-right: 20px;
|
||||
list-style: none;
|
||||
:deep(.locale-select) {
|
||||
border-radius: 20px;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-1);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-btn {
|
||||
border-color: rgb(var(--gray-2));
|
||||
color: rgb(var(--gray-8));
|
||||
font-size: 16px;
|
||||
}
|
||||
.trigger-btn,
|
||||
.ref-btn {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
}
|
||||
.trigger-btn {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.user-info-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-fill-2);
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.message-popover {
|
||||
.arco-popover-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
146
frontend/src/components/password-change-modal/index.vue
Normal file
146
frontend/src/components/password-change-modal/index.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
:title="$t('passwordChange.title')"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
:footer="false"
|
||||
:width="400"
|
||||
>
|
||||
<div class="password-change-modal">
|
||||
<a-alert type="warning" class="mb-4">
|
||||
{{ $t('passwordChange.warning') }}
|
||||
</a-alert>
|
||||
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<a-form-item field="oldPassword" :label="$t('passwordChange.oldPassword')">
|
||||
<a-input-password
|
||||
v-model="formData.oldPassword"
|
||||
:placeholder="$t('passwordChange.oldPasswordPlaceholder')"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="newPassword" :label="$t('passwordChange.newPassword')">
|
||||
<a-input-password
|
||||
v-model="formData.newPassword"
|
||||
:placeholder="$t('passwordChange.newPasswordPlaceholder')"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="confirmPassword" :label="$t('passwordChange.confirmPassword')">
|
||||
<a-input-password
|
||||
v-model="formData.confirmPassword"
|
||||
:placeholder="$t('passwordChange.confirmPasswordPlaceholder')"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
long
|
||||
:loading="loading"
|
||||
>
|
||||
{{ $t('passwordChange.submit') }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { changePassword } from '@/api/auth';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const formData = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
oldPassword: [
|
||||
{ required: true, message: t('passwordChange.oldPasswordRequired') },
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: t('passwordChange.newPasswordRequired') },
|
||||
{ minLength: 6, message: t('passwordChange.passwordMinLength') },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: t('passwordChange.confirmPasswordRequired') },
|
||||
{
|
||||
validator: (value: string, callback: (error?: string) => void) => {
|
||||
if (value !== formData.newPassword) {
|
||||
callback(t('passwordChange.passwordMismatch'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const handleSubmit = async ({ errors }: { errors: Record<string, any> | undefined }) => {
|
||||
if (errors) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await changePassword({
|
||||
oldPassword: formData.oldPassword,
|
||||
newPassword: formData.newPassword,
|
||||
});
|
||||
|
||||
Message.success(t('passwordChange.success'));
|
||||
|
||||
userStore.setInfo({ passwordChanged: true });
|
||||
visible.value = false;
|
||||
emit('success');
|
||||
} catch (err: any) {
|
||||
Message.error(err?.response?.data?.msg || t('passwordChange.error'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.password-change-modal {
|
||||
padding: 8px 0;
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
frontend/src/components/tab-bar/index.vue
Normal file
101
frontend/src/components/tab-bar/index.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="tab-bar-container">
|
||||
<a-affix ref="affixRef" :offset-top="offsetTop">
|
||||
<div class="tab-bar-box">
|
||||
<div class="tab-bar-scroll">
|
||||
<div class="tags-wrap">
|
||||
<tab-item
|
||||
v-for="(tag, index) in tagList"
|
||||
:key="tag.fullPath"
|
||||
:index="index"
|
||||
:item-data="tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag-bar-operation"></div>
|
||||
</div>
|
||||
</a-affix>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import {
|
||||
listenerRouteChange,
|
||||
removeRouteListener,
|
||||
} from '@/utils/route-listener';
|
||||
import { useAppStore, useTabBarStore } from '@/store';
|
||||
import tabItem from './tab-item.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const affixRef = ref();
|
||||
const tagList = computed(() => {
|
||||
return tabBarStore.getTabList;
|
||||
});
|
||||
const offsetTop = computed(() => {
|
||||
return appStore.navbar ? 60 : 0;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => appStore.navbar,
|
||||
() => {
|
||||
affixRef.value.updatePosition();
|
||||
}
|
||||
);
|
||||
listenerRouteChange((route: RouteLocationNormalized) => {
|
||||
if (
|
||||
!route.meta.noAffix &&
|
||||
!tagList.value.some((tag) => tag.fullPath === route.fullPath)
|
||||
) {
|
||||
tabBarStore.updateTabList(route);
|
||||
}
|
||||
}, true);
|
||||
|
||||
onUnmounted(() => {
|
||||
removeRouteListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tab-bar-container {
|
||||
position: relative;
|
||||
background-color: var(--color-bg-2);
|
||||
.tab-bar-box {
|
||||
display: flex;
|
||||
padding: 0 0 0 20px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
.tab-bar-scroll {
|
||||
height: 32px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
.tags-wrap {
|
||||
padding: 4px 0;
|
||||
height: 48px;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
|
||||
:deep(.arco-tag) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
&:first-child {
|
||||
.arco-tag-close-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-bar-operation {
|
||||
width: 100px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
frontend/src/components/tab-bar/readme.md
Normal file
12
frontend/src/components/tab-bar/readme.md
Normal file
@@ -0,0 +1,12 @@
|
||||
## 组件说明
|
||||
|
||||
该组件非官方最终设计规范,以单独组件存在。
|
||||
|
||||
同时仅仅提供最基本的功能,后续进行优化及更改。
|
||||
|
||||
|
||||
## Component description
|
||||
|
||||
The component unofficial final design specification exists as a separate component.
|
||||
|
||||
At the same time, only the most basic functions are provided, and subsequent optimizations and changes will be made.
|
||||
200
frontend/src/components/tab-bar/tab-item.vue
Normal file
200
frontend/src/components/tab-bar/tab-item.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<a-dropdown
|
||||
trigger="contextMenu"
|
||||
:popup-max-height="false"
|
||||
@select="actionSelect"
|
||||
>
|
||||
<span
|
||||
class="arco-tag arco-tag-size-medium arco-tag-checked"
|
||||
:class="{ 'link-activated': itemData.fullPath === $route.fullPath }"
|
||||
@click="goto(itemData)"
|
||||
>
|
||||
<span class="tag-link">
|
||||
{{ $t(itemData.title) }}
|
||||
</span>
|
||||
<span
|
||||
class="arco-icon-hover arco-tag-icon-hover arco-icon-hover-size-medium arco-tag-close-btn"
|
||||
@click.stop="tagClose(itemData, index)"
|
||||
>
|
||||
<icon-close />
|
||||
</span>
|
||||
</span>
|
||||
<template #content>
|
||||
<a-doption :disabled="disabledReload" :value="Eaction.reload">
|
||||
<icon-refresh />
|
||||
<span>重新加载</span>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
class="sperate-line"
|
||||
:disabled="disabledCurrent"
|
||||
:value="Eaction.current"
|
||||
>
|
||||
<icon-close />
|
||||
<span>关闭当前标签页</span>
|
||||
</a-doption>
|
||||
<a-doption :disabled="disabledLeft" :value="Eaction.left">
|
||||
<icon-to-left />
|
||||
<span>关闭左侧标签页</span>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
class="sperate-line"
|
||||
:disabled="disabledRight"
|
||||
:value="Eaction.right"
|
||||
>
|
||||
<icon-to-right />
|
||||
<span>关闭右侧标签页</span>
|
||||
</a-doption>
|
||||
<a-doption :value="Eaction.others">
|
||||
<icon-swap />
|
||||
<span>关闭其它标签页</span>
|
||||
</a-doption>
|
||||
<a-doption :value="Eaction.all">
|
||||
<icon-folder-delete />
|
||||
<span>关闭全部标签页</span>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useTabBarStore } from '@/store';
|
||||
import type { TagProps } from '@/store/modules/tab-bar/types';
|
||||
import { DEFAULT_ROUTE_NAME, REDIRECT_ROUTE_NAME } from '@/router/constants';
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
enum Eaction {
|
||||
reload = 'reload',
|
||||
current = 'current',
|
||||
left = 'left',
|
||||
right = 'right',
|
||||
others = 'others',
|
||||
all = 'all',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
itemData: {
|
||||
type: Object as PropType<TagProps>,
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const goto = (tag: TagProps) => {
|
||||
router.push({ ...tag });
|
||||
};
|
||||
const tagList = computed(() => {
|
||||
return tabBarStore.getTabList;
|
||||
});
|
||||
|
||||
const disabledReload = computed(() => {
|
||||
return props.itemData.fullPath !== route.fullPath;
|
||||
});
|
||||
|
||||
const disabledCurrent = computed(() => {
|
||||
return props.index === 0;
|
||||
});
|
||||
|
||||
const disabledLeft = computed(() => {
|
||||
return [0, 1].includes(props.index);
|
||||
});
|
||||
|
||||
const disabledRight = computed(() => {
|
||||
return props.index === tagList.value.length - 1;
|
||||
});
|
||||
|
||||
const tagClose = (tag: TagProps, idx: number) => {
|
||||
tabBarStore.deleteTag(idx, tag);
|
||||
if (props.itemData.fullPath === route.fullPath) {
|
||||
const latest = tagList.value[idx - 1]; // 获取队列的前一个tab
|
||||
router.push({ name: latest.name });
|
||||
}
|
||||
};
|
||||
|
||||
const findCurrentRouteIndex = () => {
|
||||
return tagList.value.findIndex((el) => el.fullPath === route.fullPath);
|
||||
};
|
||||
const actionSelect = async (value: any) => {
|
||||
const { itemData, index } = props;
|
||||
const copyTagList = [...tagList.value];
|
||||
if (value === Eaction.current) {
|
||||
tagClose(itemData, index);
|
||||
} else if (value === Eaction.left) {
|
||||
const currentRouteIdx = findCurrentRouteIndex();
|
||||
copyTagList.splice(1, props.index - 1);
|
||||
|
||||
tabBarStore.freshTabList(copyTagList);
|
||||
if (currentRouteIdx < index) {
|
||||
router.push({ name: itemData.name });
|
||||
}
|
||||
} else if (value === Eaction.right) {
|
||||
const currentRouteIdx = findCurrentRouteIndex();
|
||||
copyTagList.splice(props.index + 1);
|
||||
|
||||
tabBarStore.freshTabList(copyTagList);
|
||||
if (currentRouteIdx > index) {
|
||||
router.push({ name: itemData.name });
|
||||
}
|
||||
} else if (value === Eaction.others) {
|
||||
const filterList = tagList.value.filter((el, idx) => {
|
||||
return idx === 0 || idx === props.index;
|
||||
});
|
||||
tabBarStore.freshTabList(filterList);
|
||||
router.push({ name: itemData.name });
|
||||
} else if (value === Eaction.reload) {
|
||||
tabBarStore.deleteCache(itemData);
|
||||
await router.push({
|
||||
name: REDIRECT_ROUTE_NAME,
|
||||
params: {
|
||||
path: route.fullPath,
|
||||
},
|
||||
});
|
||||
tabBarStore.addCache(itemData.name);
|
||||
} else {
|
||||
tabBarStore.resetTabList();
|
||||
router.push({ name: DEFAULT_ROUTE_NAME });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tag-link {
|
||||
color: var(--color-text-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.link-activated {
|
||||
color: rgb(var(--link-6));
|
||||
.tag-link {
|
||||
color: rgb(var(--link-6));
|
||||
}
|
||||
& + .arco-tag-close-btn {
|
||||
color: rgb(var(--link-6));
|
||||
}
|
||||
}
|
||||
:deep(.arco-dropdown-option-content) {
|
||||
span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.arco-dropdown-open {
|
||||
.tag-link {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
.arco-tag-close-btn {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
}
|
||||
.sperate-line {
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
</style>
|
||||
17
frontend/src/config/settings.json
Normal file
17
frontend/src/config/settings.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"theme": "light",
|
||||
"colorWeak": false,
|
||||
"navbar": true,
|
||||
"menu": true,
|
||||
"topMenu": false,
|
||||
"hideMenu": false,
|
||||
"menuCollapse": false,
|
||||
"footer": true,
|
||||
"themeColor": "#165DFF",
|
||||
"menuWidth": 220,
|
||||
"globalSettings": false,
|
||||
"device": "desktop",
|
||||
"tabBar": false,
|
||||
"menuFromServer": false,
|
||||
"serverMenu": []
|
||||
}
|
||||
8
frontend/src/directive/index.ts
Normal file
8
frontend/src/directive/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { App } from 'vue';
|
||||
import permission from './permission';
|
||||
|
||||
export default {
|
||||
install(Vue: App) {
|
||||
Vue.directive('permission', permission);
|
||||
},
|
||||
};
|
||||
30
frontend/src/directive/permission/index.ts
Normal file
30
frontend/src/directive/permission/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { DirectiveBinding } from 'vue';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
|
||||
const { value } = binding;
|
||||
const userStore = useUserStore();
|
||||
const { role } = userStore;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
const permissionValues = value;
|
||||
|
||||
const hasPermission = permissionValues.includes(role);
|
||||
if (!hasPermission && el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`need roles! Like v-permission="['admin','user']"`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
updated(el: HTMLElement, binding: DirectiveBinding) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
};
|
||||
23
frontend/src/env.d.ts
vendored
Normal file
23
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_TRACKING_APP_KEY?: string;
|
||||
readonly VITE_TRACKING_REPORT_URL?: string;
|
||||
readonly VITE_TRACKING_SAMPLE_RATE?: string;
|
||||
readonly VITE_TRACKING_MAX_BATCH_SIZE?: string;
|
||||
readonly VITE_TRACKING_FLUSH_INTERVAL?: string;
|
||||
readonly VITE_TRACKING_CUSTOM_FLUSH_INTERVAL?: string;
|
||||
}
|
||||
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
[elem: string]: any;
|
||||
}
|
||||
}
|
||||
27
frontend/src/hooks/chart-option.ts
Normal file
27
frontend/src/hooks/chart-option.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { computed } from 'vue';
|
||||
import { EChartsOption } from 'echarts';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
// for code hints
|
||||
// import { SeriesOption } from 'echarts';
|
||||
// Because there are so many configuration items, this provides a relatively convenient code hint.
|
||||
// When using vue, pay attention to the reactive issues. It is necessary to ensure that corresponding functions can be triggered, TypeScript does not report errors, and code writing is convenient.
|
||||
interface optionsFn {
|
||||
(isDark: boolean): EChartsOption;
|
||||
}
|
||||
|
||||
export default function useChartOption(sourceOption: optionsFn) {
|
||||
const appStore = useAppStore();
|
||||
const isDark = computed(() => {
|
||||
return appStore.theme === 'dark';
|
||||
});
|
||||
// echarts support https://echarts.apache.org/zh/theme-builder.html
|
||||
// It's not used here
|
||||
// TODO echarts themes
|
||||
const chartOption = computed<EChartsOption>(() => {
|
||||
return sourceOption(isDark.value);
|
||||
});
|
||||
return {
|
||||
chartOption,
|
||||
};
|
||||
}
|
||||
16
frontend/src/hooks/loading.ts
Normal file
16
frontend/src/hooks/loading.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export default function useLoading(initValue = false) {
|
||||
const loading = ref(initValue);
|
||||
const setLoading = (value: boolean) => {
|
||||
loading.value = value;
|
||||
};
|
||||
const toggle = () => {
|
||||
loading.value = !loading.value;
|
||||
};
|
||||
return {
|
||||
loading,
|
||||
setLoading,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
22
frontend/src/hooks/locale.ts
Normal file
22
frontend/src/hooks/locale.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
|
||||
export default function useLocale() {
|
||||
const i18 = useI18n();
|
||||
const currentLocale = computed(() => {
|
||||
return i18.locale.value;
|
||||
});
|
||||
const changeLocale = (value: string) => {
|
||||
if (i18.locale.value === value) {
|
||||
return;
|
||||
}
|
||||
i18.locale.value = value;
|
||||
localStorage.setItem('arco-locale', value);
|
||||
Message.success(i18.t('navbar.action.locale'));
|
||||
};
|
||||
return {
|
||||
currentLocale,
|
||||
changeLocale,
|
||||
};
|
||||
}
|
||||
42
frontend/src/hooks/permission.ts
Normal file
42
frontend/src/hooks/permission.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export default function usePermission() {
|
||||
const userStore = useUserStore();
|
||||
return {
|
||||
accessRouter(route: RouteLocationNormalized | RouteRecordRaw) {
|
||||
if (!route.meta?.requiresAuth) {
|
||||
return true;
|
||||
}
|
||||
if (!route.meta?.roles) {
|
||||
return true;
|
||||
}
|
||||
if (route.meta?.roles?.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
if (route.meta?.roles?.includes(userStore.role)) {
|
||||
return true;
|
||||
}
|
||||
if (route.meta?.roles?.includes('system_admin') && userStore.isSystemAdmin) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
findFirstPermissionRoute(_routers: any, role = 'admin') {
|
||||
const cloneRouters = [..._routers];
|
||||
while (cloneRouters.length) {
|
||||
const firstElement = cloneRouters.shift();
|
||||
if (
|
||||
firstElement?.meta?.roles?.find((el: string[]) => {
|
||||
return el.includes('*') || el.includes(role);
|
||||
})
|
||||
)
|
||||
return { name: firstElement.name };
|
||||
if (firstElement?.children) {
|
||||
cloneRouters.push(...firstElement.children);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
26
frontend/src/hooks/request.ts
Normal file
26
frontend/src/hooks/request.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ref, UnwrapRef } from 'vue';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { HttpResponse } from '@/api/interceptor';
|
||||
import useLoading from './loading';
|
||||
|
||||
// use to fetch list
|
||||
// Don't use async function. It doesn't work in async function.
|
||||
// Use the bind function to add parameters
|
||||
// example: useRequest(api.bind(null, {}))
|
||||
|
||||
export default function useRequest<T>(
|
||||
api: () => Promise<AxiosResponse<HttpResponse>>,
|
||||
defaultValue = [] as unknown as T,
|
||||
isLoading = true
|
||||
) {
|
||||
const { loading, setLoading } = useLoading(isLoading);
|
||||
const response = ref<T>(defaultValue);
|
||||
api()
|
||||
.then((res) => {
|
||||
response.value = res.data as unknown as UnwrapRef<T>;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
return { loading, response };
|
||||
}
|
||||
32
frontend/src/hooks/responsive.ts
Normal file
32
frontend/src/hooks/responsive.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { onMounted, onBeforeMount, onBeforeUnmount } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { useAppStore } from '@/store';
|
||||
import { addEventListen, removeEventListen } from '@/utils/event';
|
||||
|
||||
const WIDTH = 992; // https://arco.design/vue/component/grid#responsivevalue
|
||||
|
||||
function queryDevice() {
|
||||
const rect = document.body.getBoundingClientRect();
|
||||
return rect.width - 1 < WIDTH;
|
||||
}
|
||||
|
||||
export default function useResponsive(immediate?: boolean) {
|
||||
const appStore = useAppStore();
|
||||
function resizeHandler() {
|
||||
if (!document.hidden) {
|
||||
const isMobile = queryDevice();
|
||||
appStore.toggleDevice(isMobile ? 'mobile' : 'desktop');
|
||||
appStore.toggleMenu(isMobile);
|
||||
}
|
||||
}
|
||||
const debounceFn = useDebounceFn(resizeHandler, 100);
|
||||
onMounted(() => {
|
||||
if (immediate) debounceFn();
|
||||
});
|
||||
onBeforeMount(() => {
|
||||
addEventListen(window, 'resize', debounceFn);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
removeEventListen(window, 'resize', debounceFn);
|
||||
});
|
||||
}
|
||||
12
frontend/src/hooks/themes.ts
Normal file
12
frontend/src/hooks/themes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { computed } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
export default function useThemes() {
|
||||
const appStore = useAppStore();
|
||||
const isDark = computed(() => {
|
||||
return appStore.theme === 'dark';
|
||||
});
|
||||
return {
|
||||
isDark,
|
||||
};
|
||||
}
|
||||
24
frontend/src/hooks/user.ts
Normal file
24
frontend/src/hooks/user.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export default function useUser() {
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const logout = async (logoutTo?: string) => {
|
||||
await userStore.logout();
|
||||
const currentRoute = router.currentRoute.value;
|
||||
Message.success('登出成功');
|
||||
router.push({
|
||||
name: logoutTo && typeof logoutTo === 'string' ? logoutTo : 'login',
|
||||
query: {
|
||||
...router.currentRoute.value.query,
|
||||
redirect: currentRoute.name as string,
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
logout,
|
||||
};
|
||||
}
|
||||
16
frontend/src/hooks/visible.ts
Normal file
16
frontend/src/hooks/visible.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export default function useVisible(initValue = false) {
|
||||
const visible = ref(initValue);
|
||||
const setVisible = (value: boolean) => {
|
||||
visible.value = value;
|
||||
};
|
||||
const toggle = () => {
|
||||
visible.value = !visible.value;
|
||||
};
|
||||
return {
|
||||
visible,
|
||||
setVisible,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
180
frontend/src/layout/default-layout.vue
Normal file
180
frontend/src/layout/default-layout.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<a-layout class="layout" :class="{ mobile: appStore.hideMenu }">
|
||||
<div v-if="navbar" class="layout-navbar">
|
||||
<NavBar />
|
||||
</div>
|
||||
<a-layout>
|
||||
<a-layout>
|
||||
<a-layout-sider
|
||||
v-if="renderMenu"
|
||||
v-show="!hideMenu"
|
||||
class="layout-sider"
|
||||
breakpoint="xl"
|
||||
:collapsed="collapsed"
|
||||
:collapsible="true"
|
||||
:width="menuWidth"
|
||||
:style="{ paddingTop: navbar ? '60px' : '' }"
|
||||
:hide-trigger="true"
|
||||
@collapse="setCollapsed"
|
||||
>
|
||||
<div class="menu-wrapper">
|
||||
<Menu />
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
<a-drawer
|
||||
v-if="hideMenu"
|
||||
:visible="drawerVisible"
|
||||
placement="left"
|
||||
:footer="false"
|
||||
mask-closable
|
||||
:closable="false"
|
||||
@cancel="drawerCancel"
|
||||
>
|
||||
<Menu />
|
||||
</a-drawer>
|
||||
<a-layout class="layout-content" :style="paddingStyle">
|
||||
<TabBar v-if="appStore.tabBar" />
|
||||
<a-layout-content>
|
||||
<PageLayout />
|
||||
</a-layout-content>
|
||||
<Footer v-if="footer" />
|
||||
<FloatingActions />
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, provide, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
import NavBar from '@/components/navbar/index.vue';
|
||||
import Menu from '@/components/menu/index.vue';
|
||||
import Footer from '@/components/footer/index.vue';
|
||||
import TabBar from '@/components/tab-bar/index.vue';
|
||||
import FloatingActions from '@/components/floating-actions/index.vue';
|
||||
import usePermission from '@/hooks/permission';
|
||||
import useResponsive from '@/hooks/responsive';
|
||||
import PageLayout from './page-layout.vue';
|
||||
|
||||
const isInit = ref(false);
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const permission = usePermission();
|
||||
useResponsive(true);
|
||||
const navbarHeight = `60px`;
|
||||
const navbar = computed(() => appStore.navbar);
|
||||
const renderMenu = computed(() => appStore.menu && !appStore.topMenu);
|
||||
const hideMenu = computed(() => appStore.hideMenu);
|
||||
const footer = computed(() => appStore.footer);
|
||||
const menuWidth = computed(() => {
|
||||
return appStore.menuCollapse ? 48 : appStore.menuWidth;
|
||||
});
|
||||
const collapsed = computed(() => {
|
||||
return appStore.menuCollapse;
|
||||
});
|
||||
const paddingStyle = computed(() => {
|
||||
const paddingLeft =
|
||||
renderMenu.value && !hideMenu.value
|
||||
? { paddingLeft: `${menuWidth.value}px` }
|
||||
: {};
|
||||
const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {};
|
||||
return { ...paddingLeft, ...paddingTop };
|
||||
});
|
||||
const setCollapsed = (val: boolean) => {
|
||||
if (!isInit.value) return; // for page initialization menu state problem
|
||||
appStore.updateSettings({ menuCollapse: val });
|
||||
};
|
||||
watch(
|
||||
() => userStore.role,
|
||||
(roleValue) => {
|
||||
if (roleValue && !permission.accessRouter(route))
|
||||
router.push({ name: 'notFound' });
|
||||
}
|
||||
);
|
||||
const drawerVisible = ref(false);
|
||||
const drawerCancel = () => {
|
||||
drawerVisible.value = false;
|
||||
};
|
||||
provide('toggleDrawerMenu', () => {
|
||||
drawerVisible.value = !drawerVisible.value;
|
||||
});
|
||||
onMounted(() => {
|
||||
isInit.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@nav-size-height: 60px;
|
||||
@layout-max-width: 1100px;
|
||||
|
||||
.layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: @nav-size-height;
|
||||
}
|
||||
|
||||
.layout-sider {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 99;
|
||||
height: 100%;
|
||||
transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -1px;
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-color: var(--color-border);
|
||||
content: '';
|
||||
}
|
||||
|
||||
> :deep(.arco-layout-sider-children) {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-wrapper {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
:deep(.arco-menu) {
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 4px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 7px;
|
||||
background-color: var(--color-text-4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
min-height: 100vh;
|
||||
overflow-y: hidden;
|
||||
background-color: var(--color-fill-2);
|
||||
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
|
||||
}
|
||||
</style>
|
||||
67
frontend/src/layout/fullscreen-layout.vue
Normal file
67
frontend/src/layout/fullscreen-layout.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<a-layout class="fullscreen-layout">
|
||||
<div v-if="showNavbar" class="layout-navbar">
|
||||
<NavBar />
|
||||
</div>
|
||||
<a-layout-content class="layout-content" :style="contentStyle">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade" mode="out-in" appear>
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="route.meta.ignoreCache"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
<keep-alive v-else>
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAppStore } from '@/store';
|
||||
import NavBar from '@/components/navbar/index.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const route = useRoute();
|
||||
const navbar = computed(() => appStore.navbar);
|
||||
const navbarHeight = '60px';
|
||||
|
||||
// 如果路由 meta 中设置了 hideNavbar: true,则不显示公共 NavBar
|
||||
const showNavbar = computed(() => {
|
||||
return navbar.value && !route.meta?.hideNavbar;
|
||||
});
|
||||
|
||||
const contentStyle = computed(() => {
|
||||
return showNavbar.value ? { paddingTop: navbarHeight } : {};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.fullscreen-layout {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
background-color: var(--color-bg-1);
|
||||
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
|
||||
}
|
||||
</style>
|
||||
25
frontend/src/layout/page-layout.vue
Normal file
25
frontend/src/layout/page-layout.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade" mode="out-in" appear>
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="route.meta.ignoreCache"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
<keep-alive v-else :include="cacheList">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useTabBarStore } from '@/store';
|
||||
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const cacheList = computed(() => tabBarStore.getCacheList);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
23
frontend/src/locale/en-US.ts
Normal file
23
frontend/src/locale/en-US.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import localeMessageBox from '@/components/message-box/locale/en-US';
|
||||
import localeLogin from '@/views/login/locale/en-US';
|
||||
import localeSettings from './en-US/settings';
|
||||
|
||||
export default {
|
||||
'menu.dashboard': 'Dashboard',
|
||||
'menu.dashboard.workplace': 'Workplace',
|
||||
'menu.book': 'Books',
|
||||
'menu.book.list': 'Book List',
|
||||
'menu.book.detail': 'Book Detail',
|
||||
'menu.system': 'System',
|
||||
'menu.system.users': 'Users',
|
||||
'menu.system.roles': 'Roles',
|
||||
'menu.exception': 'Exception',
|
||||
'menu.exception.403': 'Forbidden',
|
||||
'menu.exception.404': 'Not Found',
|
||||
'menu.exception.500': 'Server Error',
|
||||
'navbar.docs': 'Docs',
|
||||
'navbar.action.locale': 'Switch to English',
|
||||
...localeSettings,
|
||||
...localeMessageBox,
|
||||
...localeLogin,
|
||||
};
|
||||
29
frontend/src/locale/en-US/settings.ts
Normal file
29
frontend/src/locale/en-US/settings.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export default {
|
||||
'settings.title': 'Settings',
|
||||
'settings.themeColor': 'Theme Color',
|
||||
'settings.content': 'Content Setting',
|
||||
'settings.search': 'Search',
|
||||
'settings.language': 'Language',
|
||||
'settings.navbar': 'Navbar',
|
||||
'settings.menuWidth': 'Menu Width (px)',
|
||||
'settings.navbar.theme.toLight': 'Click to use light mode',
|
||||
'settings.navbar.theme.toDark': 'Click to use dark mode',
|
||||
'settings.navbar.screen.toFull': 'Click to switch to full screen mode',
|
||||
'settings.navbar.screen.toExit': 'Click to exit the full screen mode',
|
||||
'settings.navbar.alerts': 'alerts',
|
||||
'settings.menu': 'Menu',
|
||||
'settings.topMenu': 'Top Menu',
|
||||
'settings.tabBar': 'Tab Bar',
|
||||
'settings.footer': 'Footer',
|
||||
'settings.otherSettings': 'Other Settings',
|
||||
'settings.colorWeak': 'Color Weak',
|
||||
'settings.alertContent':
|
||||
'After the configuration is only temporarily effective, if you want to really affect the project, click the "Copy Settings" button below and replace the configuration in settings.json.',
|
||||
'settings.copySettings': 'Copy Settings',
|
||||
'settings.copySettings.message':
|
||||
'Copy succeeded, please paste to file src/settings.json.',
|
||||
'settings.close': 'Close',
|
||||
'settings.color.tooltip':
|
||||
'10 gradient colors generated according to the theme color',
|
||||
'settings.menuFromServer': 'Menu From Server',
|
||||
};
|
||||
22
frontend/src/locale/index.ts
Normal file
22
frontend/src/locale/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './en-US';
|
||||
import cn from './zh-CN';
|
||||
|
||||
export const LOCALE_OPTIONS = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
];
|
||||
const defaultLocale = localStorage.getItem('arco-locale') || 'zh-CN';
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: defaultLocale,
|
||||
fallbackLocale: 'en-US',
|
||||
legacy: false,
|
||||
allowComposition: true,
|
||||
messages: {
|
||||
'en-US': en,
|
||||
'zh-CN': cn,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
43
frontend/src/locale/zh-CN.ts
Normal file
43
frontend/src/locale/zh-CN.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import localeMessageBox from '@/components/message-box/locale/zh-CN';
|
||||
import localeLogin from '@/views/login/locale/zh-CN';
|
||||
import localeSettings from './zh-CN/settings';
|
||||
|
||||
const localePasswordChange = {
|
||||
'passwordChange.title': '修改密码',
|
||||
'passwordChange.warning': '为了您的账号安全,首次登录请修改默认密码。',
|
||||
'passwordChange.oldPassword': '原密码',
|
||||
'passwordChange.oldPasswordPlaceholder': '请输入原密码',
|
||||
'passwordChange.oldPasswordRequired': '请输入原密码',
|
||||
'passwordChange.newPassword': '新密码',
|
||||
'passwordChange.newPasswordPlaceholder': '请输入新密码',
|
||||
'passwordChange.newPasswordRequired': '请输入新密码',
|
||||
'passwordChange.passwordMinLength': '密码长度不能少于6位',
|
||||
'passwordChange.confirmPassword': '确认密码',
|
||||
'passwordChange.confirmPasswordPlaceholder': '请再次输入新密码',
|
||||
'passwordChange.confirmPasswordRequired': '请再次输入新密码',
|
||||
'passwordChange.passwordMismatch': '两次输入的密码不一致',
|
||||
'passwordChange.submit': '确认修改',
|
||||
'passwordChange.success': '密码修改成功',
|
||||
'passwordChange.error': '密码修改失败',
|
||||
};
|
||||
|
||||
export default {
|
||||
'menu.dashboard': '仪表盘',
|
||||
'menu.dashboard.workplace': '工作台',
|
||||
'menu.book': '教辅管理',
|
||||
'menu.book.list': '教辅列表',
|
||||
'menu.book.detail': '教辅详情',
|
||||
'menu.system': '系统管理',
|
||||
'menu.system.users': '用户管理',
|
||||
'menu.system.roles': '角色管理',
|
||||
'menu.exception': '异常页',
|
||||
'menu.exception.403': '无权限',
|
||||
'menu.exception.404': '未找到',
|
||||
'menu.exception.500': '服务错误',
|
||||
'navbar.docs': '文档中心',
|
||||
'navbar.action.locale': '切换为中文',
|
||||
...localeSettings,
|
||||
...localeMessageBox,
|
||||
...localeLogin,
|
||||
...localePasswordChange,
|
||||
};
|
||||
29
frontend/src/locale/zh-CN/settings.ts
Normal file
29
frontend/src/locale/zh-CN/settings.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export default {
|
||||
'settings.title': '页面配置',
|
||||
'settings.themeColor': '主题色',
|
||||
'settings.content': '内容区域',
|
||||
'settings.search': '搜索',
|
||||
'settings.language': '语言',
|
||||
'settings.navbar': '导航栏',
|
||||
'settings.menuWidth': '菜单宽度 (px)',
|
||||
'settings.navbar.theme.toLight': '点击切换为亮色模式',
|
||||
'settings.navbar.theme.toDark': '点击切换为暗黑模式',
|
||||
'settings.navbar.screen.toFull': '点击切换全屏模式',
|
||||
'settings.navbar.screen.toExit': '点击退出全屏模式',
|
||||
'settings.navbar.alerts': '消息通知',
|
||||
'settings.menu': '菜单栏',
|
||||
'settings.topMenu': '顶部菜单栏',
|
||||
'settings.tabBar': '多页签',
|
||||
'settings.footer': '底部',
|
||||
'settings.otherSettings': '其他设置',
|
||||
'settings.colorWeak': '色弱模式',
|
||||
'settings.alertContent':
|
||||
'配置之后仅是临时生效,要想真正作用于项目,点击下方的 "复制配置" 按钮,将配置替换到 settings.json 中即可。',
|
||||
'settings.copySettings': '复制配置',
|
||||
'settings.copySettings.message':
|
||||
'复制成功,请粘贴到 src/settings.json 文件中',
|
||||
'settings.close': '关闭',
|
||||
'settings.color.tooltip':
|
||||
'根据主题颜色生成的 10 个梯度色(将配置复制到项目中,主题色才能对亮色 / 暗黑模式同时生效)',
|
||||
'settings.menuFromServer': '菜单来源于后台',
|
||||
};
|
||||
33
frontend/src/main.ts
Normal file
33
frontend/src/main.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createApp } from 'vue';
|
||||
import ArcoVue from '@arco-design/web-vue';
|
||||
import ArcoVueIcon from '@arco-design/web-vue/es/icon';
|
||||
import globalComponents from '@/components';
|
||||
import router from './router';
|
||||
import store from './store';
|
||||
import i18n from './locale';
|
||||
import directive from './directive';
|
||||
// import './mock'; // 禁用 mock,使用真实后端 API
|
||||
import App from './App.vue';
|
||||
// Styles are imported via arco-plugin. See config/plugin/arcoStyleImport.ts in the directory for details
|
||||
// 样式通过 arco-plugin 插件导入。详见目录文件 config/plugin/arcoStyleImport.ts
|
||||
// https://arco.design/docs/designlab/use-theme-package
|
||||
import '@/assets/style/global.less';
|
||||
import '@/api/interceptor';
|
||||
import { setupFrontendTracking, trackPageView } from '@/utils/monitor';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(ArcoVue, {});
|
||||
app.use(ArcoVueIcon);
|
||||
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
app.use(i18n);
|
||||
app.use(globalComponents);
|
||||
app.use(directive);
|
||||
setupFrontendTracking(app);
|
||||
router.afterEach((to) => {
|
||||
trackPageView(to.fullPath, { title: document.title });
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
16
frontend/src/router/app-menus/index.ts
Normal file
16
frontend/src/router/app-menus/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { appRoutes, appExternalRoutes } from '../routes';
|
||||
|
||||
const mixinRoutes = [...appRoutes, ...appExternalRoutes];
|
||||
|
||||
const appClientMenus = mixinRoutes.map((el) => {
|
||||
const { name, path, meta, redirect, children } = el;
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
meta,
|
||||
redirect,
|
||||
children,
|
||||
};
|
||||
});
|
||||
|
||||
export default appClientMenus;
|
||||
18
frontend/src/router/constants.ts
Normal file
18
frontend/src/router/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const WHITE_LIST = [
|
||||
{ name: 'notFound', children: [] },
|
||||
{ name: 'login', children: [] },
|
||||
];
|
||||
|
||||
export const NOT_FOUND = {
|
||||
name: 'notFound',
|
||||
};
|
||||
|
||||
export const REDIRECT_ROUTE_NAME = 'Redirect';
|
||||
|
||||
export const DEFAULT_ROUTE_NAME = 'Workplace';
|
||||
|
||||
export const DEFAULT_ROUTE = {
|
||||
title: 'menu.dashboard.workplace',
|
||||
name: DEFAULT_ROUTE_NAME,
|
||||
fullPath: '/dashboard/workplace',
|
||||
};
|
||||
26
frontend/src/router/guard/index.ts
Normal file
26
frontend/src/router/guard/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Router } from 'vue-router';
|
||||
import NProgress from 'nprogress';
|
||||
import { setRouteEmitter } from '@/utils/route-listener';
|
||||
import setupUserLoginInfoGuard from './userLoginInfo';
|
||||
import setupPermissionGuard from './permission';
|
||||
|
||||
function setupPageGuard(router: Router) {
|
||||
router.beforeEach(async (to) => {
|
||||
// emit route change
|
||||
setRouteEmitter(to);
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure NProgress is always finished after navigation
|
||||
function setupProgressGuard(router: Router) {
|
||||
router.afterEach(() => {
|
||||
NProgress.done();
|
||||
});
|
||||
}
|
||||
|
||||
export default function createRouteGuard(router: Router) {
|
||||
setupPageGuard(router);
|
||||
setupUserLoginInfoGuard(router);
|
||||
setupPermissionGuard(router);
|
||||
setupProgressGuard(router);
|
||||
}
|
||||
73
frontend/src/router/guard/permission.ts
Normal file
73
frontend/src/router/guard/permission.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Router, RouteRecordNormalized } from 'vue-router';
|
||||
|
||||
import usePermission from '@/hooks/permission';
|
||||
import { useUserStore, useAppStore } from '@/store';
|
||||
import { appRoutes } from '../routes';
|
||||
import { WHITE_LIST, NOT_FOUND } from '../constants';
|
||||
|
||||
let routesRegistered = false;
|
||||
|
||||
const SYSTEM_ADMIN_ROUTES = ['SystemUserManage', 'SystemRoleManage'];
|
||||
|
||||
function checkRouteExist(menus: RouteRecordNormalized[], routeName: string | symbol | null | undefined): boolean {
|
||||
if (!routeName) return false;
|
||||
return menus.some((menu) => menu.name === routeName || checkRouteExist((menu.children || []) as RouteRecordNormalized[], routeName));
|
||||
}
|
||||
|
||||
function isSystemAdminRoute(routeName: string | symbol | null | undefined): boolean {
|
||||
if (!routeName || typeof routeName !== 'string') return false;
|
||||
return SYSTEM_ADMIN_ROUTES.some((prefix) => routeName.startsWith(prefix));
|
||||
}
|
||||
|
||||
export default function setupPermissionGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const Permission = usePermission();
|
||||
const permissionsAllow = Permission.accessRouter(to);
|
||||
|
||||
if (isSystemAdminRoute(to.name) && !userStore.isSystemAdmin) {
|
||||
next({ name: '403' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (appStore.menuFromServer) {
|
||||
const isInWhitelist = WHITE_LIST.find((el) => el.name === to.name);
|
||||
if (!appStore.appAsyncMenus.length && !isInWhitelist) {
|
||||
await appStore.fetchServerMenuConfig();
|
||||
}
|
||||
|
||||
if (appStore.appAsyncMenus.length > 0 && !routesRegistered) {
|
||||
const registerRoutes = (routes: RouteRecordNormalized[]) => {
|
||||
routes.forEach((route) => {
|
||||
if (route.path?.startsWith('http://') || route.path?.startsWith('https://')) return;
|
||||
if (route.name && !router.hasRoute(route.name as string)) router.addRoute(route);
|
||||
if (route.children?.length) registerRoutes(route.children as RouteRecordNormalized[]);
|
||||
});
|
||||
};
|
||||
registerRoutes(appStore.appAsyncMenus);
|
||||
routesRegistered = true;
|
||||
next({ ...to, replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const exist = checkRouteExist(appStore.appAsyncMenus, to.name) || WHITE_LIST.some((el) => el.name === to.name);
|
||||
if (exist && permissionsAllow) {
|
||||
next();
|
||||
} else {
|
||||
next(NOT_FOUND);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (permissionsAllow) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next(Permission.findFirstPermissionRoute(appRoutes, userStore.role) || NOT_FOUND);
|
||||
});
|
||||
}
|
||||
|
||||
export function resetRouteRegistration() {
|
||||
routesRegistered = false;
|
||||
}
|
||||
43
frontend/src/router/guard/userLoginInfo.ts
Normal file
43
frontend/src/router/guard/userLoginInfo.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Router, LocationQueryRaw } from 'vue-router';
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
|
||||
import { useUserStore } from '@/store';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
|
||||
export default function setupUserLoginInfoGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start();
|
||||
const userStore = useUserStore();
|
||||
if (isLogin()) {
|
||||
if (userStore.role) {
|
||||
next();
|
||||
} else {
|
||||
try {
|
||||
await userStore.info();
|
||||
next();
|
||||
} catch (error) {
|
||||
await userStore.logout();
|
||||
next({
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.name,
|
||||
...to.query,
|
||||
} as LocationQueryRaw,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (to.name === 'login') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next({
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.name,
|
||||
...to.query,
|
||||
} as LocationQueryRaw,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
37
frontend/src/router/index.ts
Normal file
37
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
import 'nprogress/nprogress.css';
|
||||
|
||||
import { appRoutes } from './routes';
|
||||
import { REDIRECT_MAIN, NOT_FOUND_ROUTE } from './routes/base';
|
||||
import createRouteGuard from './guard';
|
||||
|
||||
NProgress.configure({ showSpinner: false }); // NProgress Configuration
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: 'login',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/Login/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
...appRoutes,
|
||||
REDIRECT_MAIN,
|
||||
NOT_FOUND_ROUTE,
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
createRouteGuard(router);
|
||||
|
||||
export default router;
|
||||
32
frontend/src/router/routes/base.ts
Normal file
32
frontend/src/router/routes/base.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { REDIRECT_ROUTE_NAME } from '@/router/constants';
|
||||
|
||||
export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue');
|
||||
export const FULLSCREEN_LAYOUT = () => import('@/layout/fullscreen-layout.vue');
|
||||
|
||||
export const REDIRECT_MAIN: RouteRecordRaw = {
|
||||
path: '/redirect',
|
||||
name: 'redirectWrapper',
|
||||
component: DEFAULT_LAYOUT,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/redirect/:path',
|
||||
name: REDIRECT_ROUTE_NAME,
|
||||
component: () => import('@/views/Redirect/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const NOT_FOUND_ROUTE: RouteRecordRaw = {
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'notFound',
|
||||
component: () => import('@/views/Exception/404/index.vue'),
|
||||
};
|
||||
25
frontend/src/router/routes/index.ts
Normal file
25
frontend/src/router/routes/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
|
||||
const modules = import.meta.glob('./modules/*.ts', { eager: true });
|
||||
const externalModules = import.meta.glob('./externalModules/*.ts', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
function formatModules(_modules: any, result: RouteRecordNormalized[]) {
|
||||
Object.keys(_modules).forEach((key) => {
|
||||
const defaultModule = _modules[key].default;
|
||||
if (!defaultModule) return;
|
||||
const moduleList = Array.isArray(defaultModule)
|
||||
? [...defaultModule]
|
||||
: [defaultModule];
|
||||
result.push(...moduleList);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export const appRoutes: RouteRecordNormalized[] = formatModules(modules, []);
|
||||
|
||||
export const appExternalRoutes: RouteRecordNormalized[] = formatModules(
|
||||
externalModules,
|
||||
[]
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user