Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a86c936ee | |||
| 9acb9d380d | |||
| 47c9d60f02 | |||
| cd4dcd06f8 | |||
| 783e212bb1 | |||
| 0c7d2d7400 | |||
| 70784fc70b | |||
| 347a06e48b | |||
| fff2cfe395 | |||
| a00bcd6c73 | |||
| b3157216bb | |||
| d849bc1539 | |||
| 74a0508755 | |||
| 7999fd14c8 | |||
| 5ef1b8ec8d | |||
| 855f103ce8 | |||
| d81969b4b5 | |||
| 04a35faba3 | |||
| 1325807257 | |||
| d40d46a434 | |||
| 2881b866a6 | |||
| 72ae73764a | |||
| 51a34e9ec6 | |||
| 303995a93f | |||
| d774370a4b | |||
| a6e8cc25ec | |||
| 8299b6bbc9 |
@@ -41,3 +41,11 @@ homePC/
|
|||||||
|
|
||||||
# 臉媒啪脻偶芒膮赂藝脻 / SQL dump(藳钮藵艡掳膰膮啪偶芒)
|
# 臉媒啪脻偶芒膮赂藝脻 / SQL dump(藳钮藵艡掳膰膮啪偶芒)
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
|
# 鏁忔劅鑴氭湰(姘镐笉涓婁紶)
|
||||||
|
backend/scripts/reset_password.py
|
||||||
|
|
||||||
|
# 前端测试/占位图片(题图上传测试产物)
|
||||||
|
frontend/src/assets/*.jpg
|
||||||
|
frontend/src/assets/*.jpeg
|
||||||
|
frontend/src/assets/*.png
|
||||||
|
|||||||
@@ -47,10 +47,11 @@
|
|||||||
|
|
||||||
| Phase | 状态 |
|
| Phase | 状态 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Phase 0 环境 + Git | 单位机进行中 |
|
| Phase 0 环境 + Git | ✅ 已完成 |
|
||||||
| Phase 1 后端骨架 | 家用机已完成,单位机走"路径 B 迁移" |
|
| Phase 1 后端骨架 + 前端骨架 | ✅ 已完成 |
|
||||||
| Phase 1 前端骨架 | 未开始 |
|
| Phase 2 仪表盘 + 批量导入 + 写权限收紧 | ✅ 已完成 |
|
||||||
| Phase 2-5 | 未开始 |
|
| Phase 3 知识库基础设施 | 进行中 — 已完成至 Task 3(知识库树形视图按来源分组),语义搜索尚未做 |
|
||||||
|
| Phase 4–5 | 未开始 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -187,6 +188,7 @@ uvicorn app.main:app --reload
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v1 | 2026-05-06 | 初版,作为单位机仓库门牌 |
|
| v1 | 2026-05-06 | 初版,作为单位机仓库门牌 |
|
||||||
| **v2** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** |
|
| **v2** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** |
|
||||||
|
| **v3** | **2026-05-27** | **进度校准:Phase 1/2 标为完成;Phase 3 Task 3 知识库树形视图已完成;清理"家用机/路径B"历史表述,不再保留迁移叙事** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
仪表盘 API — 题图上传 / 查询
|
||||||
|
|
||||||
|
权限:
|
||||||
|
- POST /cover — 仅 zhipianren / zebian(biandao 返回 403)
|
||||||
|
- GET /cover — 三角色均可读
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, require_role
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.cover_settings import CoverSettings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/dashboard", tags=["仪表盘"])
|
||||||
|
|
||||||
|
# 题图文件存放目录(相对于 backend/)
|
||||||
|
STATIC_COVERS_DIR = Path(__file__).parent.parent.parent / "static" / "covers"
|
||||||
|
STATIC_COVERS_URL = "/static/covers"
|
||||||
|
|
||||||
|
# 确保目录存在
|
||||||
|
STATIC_COVERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||||
|
MAX_SIZE = 5 * 1024 * 1024 # 5MB
|
||||||
|
|
||||||
|
|
||||||
|
def require_upload_role():
|
||||||
|
return require_role(UserRole.zhipianren, UserRole.zebian)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cover")
|
||||||
|
def get_current_cover(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""查询当前题图设置(三角色均可读)。"""
|
||||||
|
row = session.exec(
|
||||||
|
select(CoverSettings).where(CoverSettings.key == "dashboard_cover")
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return {"cover_path": None, "episode_number": None, "episode_title": None}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cover_path": row.cover_path,
|
||||||
|
"episode_number": row.episode_number,
|
||||||
|
"episode_title": row.episode_title,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cover")
|
||||||
|
def upload_cover(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
episode_number: int = Form(...),
|
||||||
|
episode_title: str = Form(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(require_upload_role()),
|
||||||
|
):
|
||||||
|
"""上传题图(仅制片人/责编)。
|
||||||
|
|
||||||
|
文件存 static/covers/{uuid}.{ext},更新 cover_settings 表。
|
||||||
|
"""
|
||||||
|
# 校验文件类型
|
||||||
|
if file.content_type not in ALLOWED_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"仅支持 PNG/JPG/WEBP,当前:{file.content_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 校验文件大小
|
||||||
|
contents = file.file.read()
|
||||||
|
if len(contents) > MAX_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="文件大小不能超过 5MB",
|
||||||
|
)
|
||||||
|
file.file.seek(0)
|
||||||
|
|
||||||
|
# 生成文件名
|
||||||
|
ext = file.filename.split(".")[-1] if "." in file.filename else "jpg"
|
||||||
|
safe_ext = ext.lower() if ext.lower() in {"png", "jpg", "jpeg", "webp"} else "jpg"
|
||||||
|
filename = f"{uuid.uuid4().hex}.{safe_ext}"
|
||||||
|
filepath = STATIC_COVERS_DIR / filename
|
||||||
|
|
||||||
|
# 写文件
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(contents)
|
||||||
|
|
||||||
|
cover_path = f"{STATIC_COVERS_URL}/{filename}"
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
row = session.exec(
|
||||||
|
select(CoverSettings).where(CoverSettings.key == "dashboard_cover")
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
row.cover_path = cover_path
|
||||||
|
row.episode_number = episode_number
|
||||||
|
row.episode_title = episode_title
|
||||||
|
row.updated_at = now
|
||||||
|
else:
|
||||||
|
row = CoverSettings(
|
||||||
|
key="dashboard_cover",
|
||||||
|
cover_path=cover_path,
|
||||||
|
episode_number=episode_number,
|
||||||
|
episode_title=episode_title,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"success": True, "cover_path": cover_path}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
知识库 API — 上传 / 列表 / 删除 / 来源筛选 / 语义搜索
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, status
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, require_role
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.services.knowledge_service import KnowledgeService
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/knowledge", tags=["知识库"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_md_files(
|
||||||
|
files: list[UploadFile] = File(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
上传单个或多个 .md 文件,解析 frontmatter,写入知识库(含向量)。
|
||||||
|
仅制片人/责编可用。
|
||||||
|
"""
|
||||||
|
svc = KnowledgeService()
|
||||||
|
results = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
if not f.filename.endswith(".md"):
|
||||||
|
errors.append({"file": f.filename, "error": "仅支持 .md 文件"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = await f.read()
|
||||||
|
item = svc.store_md_file(content, f.filename)
|
||||||
|
results.append({
|
||||||
|
"id": item.id,
|
||||||
|
"title": item.title,
|
||||||
|
"source_type": item.source_type,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
errors.append({"file": f.filename, "error": str(e)})
|
||||||
|
|
||||||
|
return {"uploaded": results, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items")
|
||||||
|
def list_knowledge_items(
|
||||||
|
source_type: Optional[str] = Query(None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
知识库列表,支持按 source_type 筛选。三角色均可读。
|
||||||
|
"""
|
||||||
|
svc = KnowledgeService()
|
||||||
|
items = svc.list_items(source_type=source_type)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{item_id}")
|
||||||
|
def delete_knowledge_item(
|
||||||
|
item_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian)),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除知识库条目(级联删除向量)。仅制片人/责编可用。
|
||||||
|
"""
|
||||||
|
svc = KnowledgeService()
|
||||||
|
deleted = svc.delete_item(item_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="条目不存在")
|
||||||
|
return {"message": "删除成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sources")
|
||||||
|
def list_distinct_sources(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
返回库里所有不重复的具体出处(source_detail),供来源筛选下拉动态生成。
|
||||||
|
三角色均可读。
|
||||||
|
"""
|
||||||
|
svc = KnowledgeService()
|
||||||
|
sources = svc.get_distinct_sources()
|
||||||
|
return [{"source": s} for s in sources]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/grouped")
|
||||||
|
def get_grouped_knowledge_items(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
返回按 source_type → source_detail 两层聚合的树形结构,
|
||||||
|
含「全部」根节点,供知识库树形导航使用。
|
||||||
|
三角色均可读。
|
||||||
|
"""
|
||||||
|
svc = KnowledgeService()
|
||||||
|
return svc.get_grouped_items()
|
||||||
|
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
top_k: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/search")
|
||||||
|
def search_knowledge(
|
||||||
|
body: SearchRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
语义检索:输入一段文字,返回最相关的知识库条目及相似度。
|
||||||
|
查询向量用 type="query"(区分于存入时的 type="db")。
|
||||||
|
三角色均可读。
|
||||||
|
"""
|
||||||
|
svc = KnowledgeService()
|
||||||
|
results = svc.search_similar(query_text=body.query, top_k=body.top_k)
|
||||||
|
return {
|
||||||
|
"results": results,
|
||||||
|
"query": body.query,
|
||||||
|
"count": len(results),
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
排期 API — 查询未来排播计划
|
||||||
|
|
||||||
|
降级逻辑:
|
||||||
|
- schedules 表有数据 → 从 schedules 取未来 limit 条 JOIN episodes
|
||||||
|
- schedules 表无数据 → 从 episodes 表取最近 limit 期待播期次(air_date 升序)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.episode import Episode
|
||||||
|
from app.models.schedule import Schedule
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/schedules", tags=["排期"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/upcoming")
|
||||||
|
def list_upcoming_schedules(
|
||||||
|
limit: int = 6,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""获取未来排播计划(三角色均可读)。
|
||||||
|
|
||||||
|
优先从 schedules 表取,schedules 无数据时降级取 episodes 最近待播期次。
|
||||||
|
"""
|
||||||
|
# 先查 schedules
|
||||||
|
statement = (
|
||||||
|
select(Schedule, Episode)
|
||||||
|
.join(Episode, Schedule.episode_id == Episode.id, isouter=True)
|
||||||
|
.where(Schedule.planned_air_date >= Episode.air_date)
|
||||||
|
.order_by(Schedule.planned_air_date.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
results = session.exec(statement).all()
|
||||||
|
|
||||||
|
# 降级:从 episodes 直接取(当 schedules 为空或不足时)
|
||||||
|
if not results or len(results) < limit:
|
||||||
|
fallback_stmt = (
|
||||||
|
select(Episode)
|
||||||
|
.order_by(Episode.air_date.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
episodes = session.exec(fallback_stmt).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"episode_number": ep.episode_number,
|
||||||
|
"program_name": ep.program_name,
|
||||||
|
"planned_air_date": str(ep.air_date),
|
||||||
|
"editor_name_snapshot": ep.editor_name_snapshot,
|
||||||
|
}
|
||||||
|
for ep in episodes
|
||||||
|
]
|
||||||
|
|
||||||
|
# schedules 有数据
|
||||||
|
output = []
|
||||||
|
for schedule, episode in results:
|
||||||
|
if episode is None:
|
||||||
|
continue
|
||||||
|
output.append({
|
||||||
|
"episode_number": episode.episode_number,
|
||||||
|
"program_name": episode.program_name,
|
||||||
|
"planned_air_date": str(schedule.planned_air_date),
|
||||||
|
"editor_name_snapshot": episode.editor_name_snapshot,
|
||||||
|
})
|
||||||
|
return output
|
||||||
@@ -16,6 +16,10 @@ _DATABASE_URL = os.environ.get("DATABASE_URL")
|
|||||||
_SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-to-a-random-string-in-production")
|
_SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-to-a-random-string-in-production")
|
||||||
_SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "86400"))
|
_SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "86400"))
|
||||||
|
|
||||||
|
# MiniMax Embedding API 凭证
|
||||||
|
_MINIMAX_EMBED_API_KEY = os.environ.get("MINIMAX_EMBED_API_KEY", "")
|
||||||
|
_MINIMAX_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "")
|
||||||
|
|
||||||
# 验证必需配置
|
# 验证必需配置
|
||||||
if not _DATABASE_URL:
|
if not _DATABASE_URL:
|
||||||
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
|
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
|
||||||
@@ -25,6 +29,8 @@ class Settings:
|
|||||||
DATABASE_URL: str = _DATABASE_URL
|
DATABASE_URL: str = _DATABASE_URL
|
||||||
SECRET_KEY: str = _SECRET_KEY
|
SECRET_KEY: str = _SECRET_KEY
|
||||||
SESSION_MAX_AGE: int = _SESSION_MAX_AGE
|
SESSION_MAX_AGE: int = _SESSION_MAX_AGE
|
||||||
|
MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY
|
||||||
|
MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ from fastapi import FastAPI
|
|||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.episodes import router as episodes_router
|
from app.api.episodes import router as episodes_router
|
||||||
from app.api.imports import router as imports_router
|
from app.api.imports import router as imports_router
|
||||||
from app.api.users import router as users_router
|
from app.api.users import router as users_router
|
||||||
from app.api.yearly_targets import router as yearly_targets_router
|
from app.api.yearly_targets import router as yearly_targets_router
|
||||||
|
from app.api.dashboard import router as dashboard_router
|
||||||
|
from app.api.schedules import router as schedules_router
|
||||||
|
from app.api.knowledge import router as knowledge_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
app = FastAPI(title="军事科技工作台", version="0.1.0")
|
app = FastAPI(title="军事科技工作台", version="0.1.0")
|
||||||
@@ -42,3 +48,11 @@ app.include_router(episodes_router)
|
|||||||
app.include_router(imports_router)
|
app.include_router(imports_router)
|
||||||
app.include_router(yearly_targets_router)
|
app.include_router(yearly_targets_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
|
app.include_router(dashboard_router)
|
||||||
|
app.include_router(schedules_router)
|
||||||
|
app.include_router(knowledge_router)
|
||||||
|
|
||||||
|
# 挂载静态文件目录(题图海报)
|
||||||
|
_static_dir = Path(__file__).parent.parent / "static"
|
||||||
|
if _static_dir.exists():
|
||||||
|
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
题图设置模型 — SQLModel
|
||||||
|
|
||||||
|
只存当前展示的一张题图,不动 episodes 表。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy import DateTime as SADateTime
|
||||||
|
from sqlalchemy import Integer as SAInteger
|
||||||
|
from sqlalchemy import Text
|
||||||
|
from sqlalchemy.sql import func as sa_func
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class CoverSettings(SQLModel, table=True):
|
||||||
|
__tablename__ = "cover_settings"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
key: str = Field(default="dashboard_cover", max_length=50)
|
||||||
|
cover_path: str | None = Field(default=None) # 当前题图文件路径
|
||||||
|
episode_number: int | None = Field(default=None) # 关联期次号
|
||||||
|
episode_title: str | None = Field(default=None) # 关联期次节目名
|
||||||
|
updated_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
知识库模型 — SQLModel
|
||||||
|
对应 knowledge_items 和 knowledge_embeddings 两张表
|
||||||
|
embedding 字段使用 pgvector.Vector(对应 PG vector(1536))
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime as SADateTime, Text, Integer
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.sql import func as sa_func
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
from pgvector.sqlalchemy import Vector
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeItem(SQLModel, table=True):
|
||||||
|
"""知识库条目(knowledge_items)"""
|
||||||
|
__tablename__ = "knowledge_items"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
title: str = Field(max_length=300)
|
||||||
|
content_md: Optional[str] = Field(default=None)
|
||||||
|
source_type: str = Field(default="manual", max_length=30)
|
||||||
|
source_file_name: Optional[str] = Field(default=None, max_length=300)
|
||||||
|
source_url: Optional[str] = Field(default=None, max_length=1000)
|
||||||
|
author: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
publish_date: Optional[date] = Field(default=None)
|
||||||
|
tags: Any = Field(default=None, sa_column=Column(JSONB, default=[]))
|
||||||
|
related_entities: Any = Field(default=None, sa_column=Column(JSONB, default=[]))
|
||||||
|
related_concepts: Any = Field(default=None, sa_column=Column(JSONB, default=[]))
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||||
|
)
|
||||||
|
updated_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeEmbedding(SQLModel, table=True):
|
||||||
|
"""知识库向量(knowledge_embeddings)"""
|
||||||
|
__tablename__ = "knowledge_embeddings"
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
knowledge_id: int = Field(foreign_key="knowledge_items.id", index=True)
|
||||||
|
chunk_index: int = Field(default=0)
|
||||||
|
chunk_text: str = Field(sa_column=Column(Text, nullable=False))
|
||||||
|
embedding: Any = Field(sa_column=Column(Vector(1536), nullable=False))
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
排期模型 — SQLModel
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy import DateTime as SADateTime
|
||||||
|
from sqlalchemy import Integer as SAInteger
|
||||||
|
from sqlalchemy import Date as SADate
|
||||||
|
from sqlalchemy import Text
|
||||||
|
from sqlalchemy.sql import func as sa_func
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class Schedule(SQLModel, table=True):
|
||||||
|
__tablename__ = "schedules"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
episode_id: int | None = Field(default=None, foreign_key="episodes.id")
|
||||||
|
planned_air_date: date
|
||||||
|
status: str = Field(default="planned", max_length=20)
|
||||||
|
editor_id: int | None = Field(default=None, foreign_key="users.id")
|
||||||
|
notes: str | None = Field(default=None)
|
||||||
|
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||||
|
)
|
||||||
|
updated_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||||
|
)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Embedding 调用服务 — 封装 MiniMax embo-01
|
||||||
|
|
||||||
|
请求格式(确认自探路脚本):
|
||||||
|
POST /v1/embeddings
|
||||||
|
Body: {"model": "embo-01", "texts": [...], "type": "db"|"query"}
|
||||||
|
响应格式:
|
||||||
|
{"vectors": [[...1536 floats...]], "total_tokens": N, "base_resp": {"status_code": 0, "status_msg": "success"}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingService:
|
||||||
|
"""MiniMax embo-01 embedding 调用封装"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = settings.MINIMAX_EMBED_API_KEY
|
||||||
|
self.group_id = settings.MINIMAX_GROUP_ID
|
||||||
|
self.endpoint = "https://api.minimax.chat/v1/embeddings"
|
||||||
|
|
||||||
|
def embed(self, texts: List[str], embed_type: str = "db") -> List[List[float]]:
|
||||||
|
"""
|
||||||
|
调用 embo-01 将文本列表转为向量
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texts: 文本列表(支持批量)
|
||||||
|
embed_type: "db" = 存入库,"query" = 查询
|
||||||
|
Returns:
|
||||||
|
List[List[float]],每个元素是一组 1536 维向量
|
||||||
|
"""
|
||||||
|
if not self.api_key or self.api_key == "your_api_key_here":
|
||||||
|
raise RuntimeError("MINIMAX_EMBED_API_KEY not configured in .env")
|
||||||
|
if not self.group_id or self.group_id == "your_group_id_here":
|
||||||
|
raise RuntimeError("MINIMAX_GROUP_ID not configured in .env")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"GroupId": self.group_id,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": "embo-01",
|
||||||
|
"texts": texts,
|
||||||
|
"type": embed_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = httpx.post(self.endpoint, headers=headers, json=payload, timeout=60.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 检查业务错误
|
||||||
|
base_resp = data.get("base_resp", {})
|
||||||
|
if base_resp.get("status_code", 0) != 0:
|
||||||
|
raise RuntimeError(f"Embedding API error: {base_resp.get('status_msg', 'unknown')}")
|
||||||
|
|
||||||
|
vectors = data.get("vectors", [])
|
||||||
|
if not vectors:
|
||||||
|
raise RuntimeError("No vectors returned from embedding API")
|
||||||
|
|
||||||
|
return vectors
|
||||||
|
|
||||||
|
def embed_single(self, text: str, embed_type: str = "db") -> List[float]:
|
||||||
|
"""单文本 embedding,返回 1536 维向量列表(Python list)"""
|
||||||
|
vectors = self.embed([text], embed_type=embed_type)
|
||||||
|
return vectors[0]
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
知识库服务 — 写入向量 + 语义检索 + md 文件解析
|
||||||
|
使用 pgvector 原生 SQL 向量检索(<=> 余弦距离算子),不在 Python 侧计算
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import frontmatter
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from pgvector.sqlalchemy import Vector
|
||||||
|
|
||||||
|
from app.models.knowledge import KnowledgeItem, KnowledgeEmbedding
|
||||||
|
from app.services.embedding_service import EmbeddingService
|
||||||
|
from app.db.session import engine
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeService:
|
||||||
|
"""知识库 CRUD + 语义检索 + md 解析"""
|
||||||
|
|
||||||
|
# yaml 类型字段 → source_type 枚举映射
|
||||||
|
SOURCE_TYPE_MAP = {
|
||||||
|
"杂志文章": "military_report",
|
||||||
|
"军报": "military_report",
|
||||||
|
"节目文稿": "manuscript",
|
||||||
|
"报题单": "baoti",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 来源大类固定显示顺序(制片人 Obsidian 习惯)
|
||||||
|
SOURCE_TYPE_ORDER = [
|
||||||
|
"manuscript", # 节目文稿
|
||||||
|
"military_report", # 杂志文章
|
||||||
|
"baoti", # 报题单
|
||||||
|
"manual", # 其他
|
||||||
|
]
|
||||||
|
|
||||||
|
# 二级分组维度映射(与前端 useKnowledgeGrouping.js 的 SECONDARY_GROUP_FIELD 一致)
|
||||||
|
# key = source_type, value = 用来做二级分组的字段名,None = 不建二级节点
|
||||||
|
SECONDARY_GROUP_FIELD = {
|
||||||
|
"manuscript": "author", # 节目文稿 → 按作者(编导)
|
||||||
|
"military_report": "source_detail", # 杂志文章 → 按出处
|
||||||
|
"baoti": None, # 报题单 → 不分组
|
||||||
|
"manual": None, # 其他 → 不分组
|
||||||
|
}
|
||||||
|
|
||||||
|
SOURCE_TYPE_LABEL = {
|
||||||
|
"military_report": "杂志文章",
|
||||||
|
"manuscript": "节目文稿",
|
||||||
|
"baoti": "报题单",
|
||||||
|
"manual": "其他",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.embedder = EmbeddingService()
|
||||||
|
|
||||||
|
def parse_md_file(self, file_content: bytes, file_name: str) -> dict:
|
||||||
|
"""
|
||||||
|
解析一个 .md 文件的 yaml frontmatter + 正文,返回入库用的字典。
|
||||||
|
严格按真实样本的字段名映射,不猜测。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict 含 keys: title, content_md, source_type, author, publish_date,
|
||||||
|
source_detail, metadata(JSONB), related_entities(JSONB)
|
||||||
|
"""
|
||||||
|
content = file_content.decode("utf-8", errors="replace")
|
||||||
|
parsed = frontmatter.loads(content)
|
||||||
|
fm = parsed.metadata or {}
|
||||||
|
|
||||||
|
# —— 类型 → source_type(硬映射,不猜测)——
|
||||||
|
raw_type = str(fm.get("类型", "")).strip()
|
||||||
|
source_type = self.SOURCE_TYPE_MAP.get(raw_type, "manual")
|
||||||
|
|
||||||
|
# —— 标题:名称 或 标题——
|
||||||
|
title = str(fm.get("名称", "") or fm.get("标题", "")).strip()
|
||||||
|
if not title:
|
||||||
|
# fallback: 用正文第一行或文件名
|
||||||
|
lines = [l.strip() for l in content.split("\n") if l.strip() and not l.strip().startswith("---")]
|
||||||
|
title = lines[0] if lines else file_name
|
||||||
|
|
||||||
|
# —— 作者:作者 或 编导——
|
||||||
|
author = str(fm.get("作者", "") or fm.get("编导", "") or "").strip() or None
|
||||||
|
|
||||||
|
# —— 出处详情:期刊 + 期号(拼在一起存进 JSONB 的 source_detail)——
|
||||||
|
journal = str(fm.get("期刊", "") or "").strip()
|
||||||
|
issue = str(fm.get("期号", "") or "").strip()
|
||||||
|
if journal or issue:
|
||||||
|
source_detail = f"{journal} {issue}".strip()
|
||||||
|
else:
|
||||||
|
source_detail = None
|
||||||
|
|
||||||
|
# —— 播出日期:容错 "待补充" 等非日期文本——
|
||||||
|
raw_date = str(fm.get("播出日期", "") or "").strip()
|
||||||
|
publish_date = None
|
||||||
|
if raw_date and raw_date not in ("待补充", "待确认", ""):
|
||||||
|
try:
|
||||||
|
publish_date = date.fromisoformat(raw_date)
|
||||||
|
except ValueError:
|
||||||
|
# 非 ISO 格式,尝试 common 格式
|
||||||
|
for fmt in ("%Y-%m-%d", "%Y年%m月%d日", "%Y/%m/%d"):
|
||||||
|
try:
|
||||||
|
publish_date = date.fromisoformat(raw_date.replace("年", "-").replace("月", "-").replace("日", ""))
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# —— 权重(不展示,存 JSONB 备 Phase 4)——
|
||||||
|
weight = str(fm.get("权重", "") or "").strip() or None
|
||||||
|
|
||||||
|
# —— 相关实体(涉及装备/涉及技术/涉及厂商/主题)——
|
||||||
|
related_entities = []
|
||||||
|
for key in ("涉及装备", "涉及技术", "涉及厂商", "主题"):
|
||||||
|
val = fm.get(key)
|
||||||
|
if val:
|
||||||
|
if isinstance(val, list):
|
||||||
|
related_entities.extend(val)
|
||||||
|
elif isinstance(val, str):
|
||||||
|
# 可能是 "山东舰, 福建舰" 这样的逗号分隔字符串
|
||||||
|
for item in val.replace(",", ",").split(","):
|
||||||
|
item = item.strip()
|
||||||
|
if item:
|
||||||
|
related_entities.append(item)
|
||||||
|
|
||||||
|
# —— metadata JSONB:权重、出处详情、双链预留——
|
||||||
|
metadata = {}
|
||||||
|
if weight:
|
||||||
|
metadata["weight"] = weight
|
||||||
|
if source_detail:
|
||||||
|
metadata["source_detail"] = source_detail
|
||||||
|
# related_concepts 字段预留给双链解析(Phase 4),本 Task 原样存入
|
||||||
|
metadata["double_bracket_links"] = self._extract_double_brackets(parsed.content)
|
||||||
|
|
||||||
|
# —— 正文(去掉 frontmatter 的纯内容)——
|
||||||
|
content_md = parsed.content
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"content_md": content_md,
|
||||||
|
"source_type": source_type,
|
||||||
|
"author": author,
|
||||||
|
"publish_date": publish_date,
|
||||||
|
"metadata": metadata if metadata else None,
|
||||||
|
"related_entities": related_entities if related_entities else None,
|
||||||
|
"source_file_name": file_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _extract_double_brackets(self, text: str) -> list[str]:
|
||||||
|
"""提取 [[...]] 双链标记,原样返回列表,不解析成图谱(本 Task 留门)。"""
|
||||||
|
import re
|
||||||
|
return re.findall(r"\[\[([^\]]+)\]\]", text)
|
||||||
|
|
||||||
|
def store_md_file(self, file_content: bytes, file_name: str) -> KnowledgeItem:
|
||||||
|
"""
|
||||||
|
读取一篇 md 内容,调用 embo-01 拿到向量,写入 knowledge_items + knowledge_embeddings
|
||||||
|
"""
|
||||||
|
parsed = self.parse_md_file(file_content, file_name)
|
||||||
|
|
||||||
|
# 调用 embedding(type="db" 表示存入知识库)
|
||||||
|
embedding_list = self.embedder.embed_single(parsed["content_md"], embed_type="db")
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
item = KnowledgeItem(
|
||||||
|
title=parsed["title"],
|
||||||
|
content_md=parsed["content_md"],
|
||||||
|
source_type=parsed["source_type"],
|
||||||
|
source_file_name=parsed["source_file_name"],
|
||||||
|
author=parsed["author"],
|
||||||
|
publish_date=parsed["publish_date"],
|
||||||
|
tags=parsed["metadata"],
|
||||||
|
related_entities=parsed["related_entities"],
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
emb = KnowledgeEmbedding(
|
||||||
|
knowledge_id=item.id,
|
||||||
|
chunk_index=0,
|
||||||
|
chunk_text=parsed["content_md"],
|
||||||
|
embedding=embedding_list,
|
||||||
|
)
|
||||||
|
session.add(emb)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def delete_item(self, knowledge_id: int) -> bool:
|
||||||
|
"""删除知识库条目及其向量(CASCADE 已由 DB 层配置)。"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
item = session.get(KnowledgeItem, knowledge_id)
|
||||||
|
if item is None:
|
||||||
|
return False
|
||||||
|
session.delete(item)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_items(self, source_type: Optional[str] = None) -> list[dict]:
|
||||||
|
"""返回知识库条目列表(含 source_detail 从 metadata 解压)。"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
statement = select(KnowledgeItem).order_by(KnowledgeItem.created_at.desc())
|
||||||
|
if source_type:
|
||||||
|
statement = statement.where(KnowledgeItem.source_type == source_type)
|
||||||
|
items = session.exec(statement).all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for item in items:
|
||||||
|
# 从 tags(JSONB) 取 source_detail
|
||||||
|
tags = item.tags or {}
|
||||||
|
source_detail = tags.get("source_detail") if isinstance(tags, dict) else None
|
||||||
|
results.append({
|
||||||
|
"id": item.id,
|
||||||
|
"title": item.title,
|
||||||
|
"author": item.author,
|
||||||
|
"publish_date": item.publish_date,
|
||||||
|
"source_type": item.source_type,
|
||||||
|
"source_file_name": item.source_file_name,
|
||||||
|
"source_detail": source_detail,
|
||||||
|
"created_at": item.created_at,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_distinct_sources(self) -> list[str]:
|
||||||
|
"""返回库里所有不重复的 source_detail(动态从 JSONB 提取),供筛选下拉用。"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
items = session.exec(select(KnowledgeItem)).all()
|
||||||
|
sources = set()
|
||||||
|
for item in items:
|
||||||
|
tags = item.tags or {}
|
||||||
|
if isinstance(tags, dict) and tags.get("source_detail"):
|
||||||
|
sources.add(tags["source_detail"])
|
||||||
|
return sorted(list(sources))
|
||||||
|
|
||||||
|
def search_similar(self, query_text: str, top_k: int = 5) -> list[dict]:
|
||||||
|
"""
|
||||||
|
语义检索:查询句转为向量,用 SQL 余弦距离(<=>)在数据库层检索
|
||||||
|
返回 top_k 条相似笔记,含相似度分数 + 原文片段(SQL 端截断前 200 字)。
|
||||||
|
|
||||||
|
注意:当前取前 200 字是已知妥协(整篇向量检索无法定位中段命中点),
|
||||||
|
Phase 4a 做切块检索(chunk)时可优化为取最相关片段。
|
||||||
|
"""
|
||||||
|
query_vector = self.embedder.embed_single(query_text, embed_type="query")
|
||||||
|
vec_str = "[" + ",".join(str(v) for v in query_vector) + "]"
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
sql = f"""
|
||||||
|
SELECT
|
||||||
|
ki.id,
|
||||||
|
ki.title,
|
||||||
|
ki.source_type,
|
||||||
|
ki.author,
|
||||||
|
ki.tags,
|
||||||
|
SUBSTRING(ki.content_md, 1, 200) AS snippet,
|
||||||
|
1 - (ke.embedding <=> '{vec_str}'::vector) AS similarity
|
||||||
|
FROM knowledge_embeddings ke
|
||||||
|
JOIN knowledge_items ki ON ke.knowledge_id = ki.id
|
||||||
|
WHERE ke.chunk_index = 0
|
||||||
|
ORDER BY ke.embedding <=> '{vec_str}'::vector
|
||||||
|
LIMIT {top_k}
|
||||||
|
"""
|
||||||
|
stmt = text(sql)
|
||||||
|
rows = session.execute(stmt).all()
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
tags = r.tags or {}
|
||||||
|
source_detail = tags.get("source_detail") if isinstance(tags, dict) else None
|
||||||
|
results.append({
|
||||||
|
"id": r.id,
|
||||||
|
"title": r.title,
|
||||||
|
"source_type": r.source_type,
|
||||||
|
"author": r.author,
|
||||||
|
"source_detail": source_detail,
|
||||||
|
"snippet": r.snippet,
|
||||||
|
"similarity": round(r.similarity, 4),
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_item_count(self) -> int:
|
||||||
|
with Session(engine) as session:
|
||||||
|
return len(session.exec(select(KnowledgeItem)).all())
|
||||||
|
|
||||||
|
def get_embedding_count(self) -> int:
|
||||||
|
with Session(engine) as session:
|
||||||
|
return len(session.exec(select(KnowledgeEmbedding)).all())
|
||||||
|
|
||||||
|
def get_grouped_items(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
按 source_type → 二级字段(author / source_detail)两层聚合,返回树形结构数据。
|
||||||
|
按 SOURCE_TYPE_ORDER 固定顺序排列,仅显示有数据的大类(count > 0)。
|
||||||
|
|
||||||
|
二级节点 key 格式:`{source_type}|{二级字段名}|{字段值}`
|
||||||
|
例:manuscript|author|左鑫
|
||||||
|
military_report|source_detail|航空知识 2026年第1期
|
||||||
|
|
||||||
|
二级字段值为 null / 空字串 → 归入对应大类,不造空节点。
|
||||||
|
空大类(0条)不渲染。
|
||||||
|
"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
items = session.exec(select(KnowledgeItem)).all()
|
||||||
|
|
||||||
|
total_count = len(items)
|
||||||
|
|
||||||
|
# 按 source_type 分组:仅收集有数据的类别,再按固定顺序排列
|
||||||
|
type_groups: dict = {}
|
||||||
|
for item in items:
|
||||||
|
st = item.source_type or "manual"
|
||||||
|
if st not in type_groups:
|
||||||
|
type_groups[st] = []
|
||||||
|
type_groups[st].append(item)
|
||||||
|
|
||||||
|
# 只遍历有数据的类别,按 SOURCE_TYPE_ORDER 顺序
|
||||||
|
children = []
|
||||||
|
for st in self.SOURCE_TYPE_ORDER:
|
||||||
|
if st not in type_groups:
|
||||||
|
continue
|
||||||
|
st_items = type_groups[st]
|
||||||
|
|
||||||
|
secondary_field = self.SECONDARY_GROUP_FIELD.get(st)
|
||||||
|
grandchildren = []
|
||||||
|
|
||||||
|
if secondary_field is not None:
|
||||||
|
# 按二级字段分组
|
||||||
|
detail_groups: dict = {}
|
||||||
|
for item in st_items:
|
||||||
|
if secondary_field == "source_detail":
|
||||||
|
tags = item.tags or {}
|
||||||
|
sd = tags.get("source_detail") if isinstance(tags, dict) else None
|
||||||
|
field_val = sd
|
||||||
|
else:
|
||||||
|
field_val = (getattr(item, secondary_field, None) or "").strip() or None
|
||||||
|
|
||||||
|
if field_val not in detail_groups:
|
||||||
|
detail_groups[field_val] = []
|
||||||
|
detail_groups[field_val].append(item)
|
||||||
|
|
||||||
|
for sd, sd_items in detail_groups.items():
|
||||||
|
if sd is not None:
|
||||||
|
grandchildren.append({
|
||||||
|
"key": f"{st}|{secondary_field}|{sd}",
|
||||||
|
"label": f"{sd}({len(sd_items)}条)",
|
||||||
|
"count": len(sd_items),
|
||||||
|
})
|
||||||
|
|
||||||
|
children.append({
|
||||||
|
"key": st,
|
||||||
|
"label": f"{self.SOURCE_TYPE_LABEL.get(st, st)}({len(st_items)}条)",
|
||||||
|
"count": len(st_items),
|
||||||
|
"children": grandchildren,
|
||||||
|
})
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"key": "all",
|
||||||
|
"label": f"全部({total_count}条)",
|
||||||
|
"count": total_count,
|
||||||
|
"children": children,
|
||||||
|
}]
|
||||||
@@ -9,5 +9,6 @@ itsdangerous==2.2.0
|
|||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
python-frontmatter==1.1.0
|
||||||
pandas>=2.0.0
|
pandas>=2.0.0
|
||||||
openpyxl>=3.1.0
|
openpyxl>=3.1.0
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
清理测试数据脚本
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_env_path = Path(__file__).parent.parent / ".env"
|
||||||
|
load_dotenv(str(_env_path))
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.db.session import engine
|
||||||
|
|
||||||
|
conn = engine.connect()
|
||||||
|
|
||||||
|
# 清空知识库
|
||||||
|
conn.execute(text("DELETE FROM knowledge_embeddings"))
|
||||||
|
conn.execute(text("DELETE FROM knowledge_items"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# 查行数
|
||||||
|
ki = conn.execute(text("SELECT COUNT(*) FROM knowledge_items")).scalar()
|
||||||
|
ke = conn.execute(text("SELECT COUNT(*) FROM knowledge_embeddings")).scalar()
|
||||||
|
|
||||||
|
print(f"knowledge_items rows: {ki}")
|
||||||
|
print(f"knowledge_embeddings rows: {ke}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
探路脚本 — 调 MiniMax embo-01,打印原始返回 JSON
|
||||||
|
确认向量字段位置和维度后再写正式 service。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 加载 .env
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
_env_path = Path(__file__).parent.parent / ".env"
|
||||||
|
load_dotenv(str(_env_path))
|
||||||
|
|
||||||
|
api_key = os.environ.get("MINIMAX_EMBED_API_KEY", "")
|
||||||
|
group_id = os.environ.get("MINIMAX_GROUP_ID", "")
|
||||||
|
|
||||||
|
if not api_key or api_key == "your_api_key_here":
|
||||||
|
print("[ERROR] MINIMAX_EMBED_API_KEY not configured, please edit backend/.env")
|
||||||
|
exit(1)
|
||||||
|
if not group_id or group_id == "your_group_id_here":
|
||||||
|
print("[ERROR] MINIMAX_GROUP_ID not configured, please edit backend/.env")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print(f"API Key (first 4 chars): {api_key[:4]}...")
|
||||||
|
print(f"GroupId: {group_id}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 最小调用
|
||||||
|
test_text = "这是一段测试文本,用于验证 embo-01 接口返回结构。"
|
||||||
|
|
||||||
|
print(f"Sending request, test text: {test_text}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
"https://api.minimax.chat/v1/embeddings",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"GroupId": group_id,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={"model": "embo-01", "texts": [test_text], "type": "db"},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
print(f"HTTP status: {resp.status_code}")
|
||||||
|
print()
|
||||||
|
data = resp.json()
|
||||||
|
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
# 提取向量,验证维度
|
||||||
|
print()
|
||||||
|
print("-" * 60)
|
||||||
|
vectors = data.get("vectors", [])
|
||||||
|
if vectors and len(vectors) > 0:
|
||||||
|
embedding = vectors[0]
|
||||||
|
dim = len(embedding)
|
||||||
|
print(f"[OK] Embedding field: vectors[0]")
|
||||||
|
print(f"[OK] Embedding dimension: {dim}")
|
||||||
|
if dim != 1536:
|
||||||
|
print(f"[STOP] Dimension is NOT 1536! Got {dim} - stopping here")
|
||||||
|
else:
|
||||||
|
print(f"[OK] Dimension correct: 1536")
|
||||||
|
print(f"[OK] API call successful, structure confirmed.")
|
||||||
|
else:
|
||||||
|
print("[WARNING] vectors not found in response")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Request failed: {e}")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
全链路验证脚本 — TPS 知识库 embedding 最小链路
|
||||||
|
|
||||||
|
验证步骤:
|
||||||
|
1. 读取 backend/sample_md/ 下的 5 篇 .md 文件
|
||||||
|
2. 调用 embo-01 转成向量(打印维度)
|
||||||
|
3. 存入 knowledge_items + knowledge_embeddings(打印行数)
|
||||||
|
4. 执行语义检索(打印查询句 + 最相似笔记)
|
||||||
|
5. 查 episodes 表行数(打印,只读不动)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from sqlmodel import text
|
||||||
|
|
||||||
|
# 加载 .env
|
||||||
|
_env_path = Path(__file__).parent.parent / ".env"
|
||||||
|
load_dotenv(str(_env_path))
|
||||||
|
|
||||||
|
from app.services.knowledge_service import KnowledgeService
|
||||||
|
from app.db.session import engine
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("TPS Knowledge Base — Embedding Full链路验证")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
sample_dir = Path(__file__).parent.parent / "sample_md"
|
||||||
|
md_files = sorted(sample_dir.glob("*.md"))
|
||||||
|
print(f"\n[FIND] Found {len(md_files)} .md files in sample_md/")
|
||||||
|
|
||||||
|
ks = KnowledgeService()
|
||||||
|
|
||||||
|
# 1. 写入知识库
|
||||||
|
print("\n[STEP 1] Storing MD files into knowledge base...")
|
||||||
|
items_stored = []
|
||||||
|
for mf in md_files:
|
||||||
|
title = mf.stem # 文件名(不含扩展名)作为标题
|
||||||
|
content = mf.read_text(encoding="utf-8")
|
||||||
|
item = ks.store_md_file(
|
||||||
|
title=title,
|
||||||
|
content_md=content,
|
||||||
|
source_file_name=mf.name,
|
||||||
|
source_type="manual",
|
||||||
|
)
|
||||||
|
items_stored.append(item)
|
||||||
|
print(f" - Stored: {item.title} (id={item.id})")
|
||||||
|
|
||||||
|
ki_count = ks.get_item_count()
|
||||||
|
ke_count = ks.get_embedding_count()
|
||||||
|
print(f"\n[OK] knowledge_items rows: {ki_count}")
|
||||||
|
print(f"[OK] knowledge_embeddings rows: {ke_count}")
|
||||||
|
|
||||||
|
# 2. 语义检索
|
||||||
|
print("\n[STEP 2] Semantic search test...")
|
||||||
|
query = "五代战斗机的隐身技术有哪些关键要素?"
|
||||||
|
print(f"Query: {query}")
|
||||||
|
results = ks.search_similar(query, top_k=3)
|
||||||
|
print(f"\n[OK] Top 3 similar notes:")
|
||||||
|
for i, r in enumerate(results, 1):
|
||||||
|
print(f" {i}. [{r['similarity']}] {r['title']}")
|
||||||
|
|
||||||
|
# 3. 查 episodes 表行数(只读不动)
|
||||||
|
print("\n[STEP 3] Episodes table (read-only)...")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(text("SELECT COUNT(*) FROM episodes"))
|
||||||
|
episode_count = result.scalar()
|
||||||
|
print(f"[OK] episodes table row count: {episode_count}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Verification complete.")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 002_add_cover_settings.sql — 题图设置表
|
||||||
|
-- 执行方式: psql -U postgres -d milsci_dev -f 002_add_cover_settings.sql
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 题图设置表(只存当前展示的一张题图,不动 episodes 表)
|
||||||
|
CREATE TABLE IF NOT EXISTS cover_settings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
key VARCHAR(50) NOT NULL UNIQUE DEFAULT 'dashboard_cover',
|
||||||
|
cover_path TEXT, -- 当前题图文件路径(相对路径,如 /static/covers/xxx.jpg)
|
||||||
|
episode_number INTEGER, -- 关联期次号(可空)
|
||||||
|
episode_title TEXT, -- 关联期次节目名(可空)
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 初始化默认值行(允许为空,表示使用默认渐变底图)
|
||||||
|
INSERT INTO cover_settings (key, cover_path, episode_number, episode_title)
|
||||||
|
VALUES ('dashboard_cover', NULL, NULL, NULL)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 迁移完成
|
||||||
|
-- ============================================================
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
**本文件是制片人刘通与 Claude(Opus 4.7 高级技术顾问)的协作原则,与项目宪法(.clinerules / project_plan.md / dev_plan.md / docs/git_workflow.md)并列。新对话接手时,请连同宪法一并阅读本文件。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Claude 与我交流、沟通、协作时需要注意的原则**
|
||||||
|
|
||||||
|
1. 全程使用简体中文。
|
||||||
|
|
||||||
|
2. 我不懂代码编程,给我的解释说明和理由要尽量简明扼要。不要堆叠大量技术语言,说人话。
|
||||||
|
|
||||||
|
3. 请参阅宪法文件中你(Claude)的定位:写具体 Plan 是 Cline 的事,不要架空 Cline 的 Plan 模式。除非特别必要(Cline 反复修改都过不去的坎)可以上手,以及制片人明确指定你可以操刀的具体工作。
|
||||||
|
|
||||||
|
4. 给 Cline 的指令要用代码块封装起来,方便复制。
|
||||||
+15
-10
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 项目:TPS(Topic Planning System)中台
|
> 项目:TPS(Topic Planning System)中台
|
||||||
> 仓库:`tps-dashboard`(Gitea)
|
> 仓库:`tps-dashboard`(Gitea)
|
||||||
> 修订版本:v4 · 2026-05-14
|
> 修订版本:v5 · 2026-05-27
|
||||||
> 配套文档:`project_plan.md`(产品宪法)+ `.clinerules`(协作规则)+ `docs/git_workflow.md`(Git 操作手册)
|
> 配套文档:`project_plan.md`(产品宪法)+ `.clinerules`(协作规则)+ `docs/git_workflow.md`(Git 操作手册)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -249,19 +249,23 @@ git push origin phase0-env-ready
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 4a:核心 MVP(Week 5-6)
|
### Phase 4a:核心 MVP — 对话式 TPS(Week 5-6)
|
||||||
|
|
||||||
**模块 G**:报题单生成与下载(docxtpl 模板)
|
> v5 升级:模块 A/C/G 合成"对话式策划助手"形态(左对话右报告、脚注式引用),详见 `project_plan.md` 模块 A/C/G + 第 2.3 节 + 6.5 节红线。
|
||||||
- 模板严格遵守"双段结构"(`.clinerules` 5.4)
|
|
||||||
|
|
||||||
**模块 A**:选题查重(语义检索 + 历史收视展示)
|
**前置条件(硬门槛,不满足不开工)**:
|
||||||
- 联动模块 E 展示外拍资源
|
- 知识库笔记已批量录入(Backlog#2),收视数据已真实导入(Phase 2 遗留债)。**库空时对话助手"无米下锅",依据全空会毁掉第一印象。**
|
||||||
|
- Phase 3 的**朴素语义检索**已先行跑通(输入文本→返回最相关条目,带相关度/原文片段/来源),作为对话式报告的检索底座。
|
||||||
|
|
||||||
**模块 C**:知识库参考(检索往期文稿 / 军报)
|
**模块 A(对话式策划主入口)**:左栏编导对话、右栏 AI 加工的策划参考报告(历史是否做过/收视/审片意见/可参考节目/外拍资源/知识库依据)。严守三条交互红线:求助式不投喂、脚注式锚定引用、持续对话沉淀编导判断(见 `project_plan.md` 模块 A)。
|
||||||
|
|
||||||
**前端**:选题提交表单 + AI 反馈页面布局
|
**模块 C(知识库依据供给)**:检索结果融入右栏报告作脚注依据,而非孤立列表。
|
||||||
|
|
||||||
**🎯 Week 6 末上线 MVP**——团队真的可以用了:登录、看仪表盘、提交选题、看到查重和知识库结果、生成报题单 docx 下载。
|
**模块 G(报题单生成)**:聊定后一键按范本规范化生成 docx。**只做规范化呈现,不代写立意**(见模块 G 分寸线 + 6.5 红线)。模板严守"双段结构"(`.clinerules` 5.4)。
|
||||||
|
|
||||||
|
**AI 成本机制**:本 Phase 起对话调用大模型,按第十一章"AI 成本与额度原则"落地——按 token 记、按人+选题归因、保守上限 + 超额提醒管理员。**朴素的一张用量表即可,严禁上 AI 网关/可观测性框架等大公司重型武器。**
|
||||||
|
|
||||||
|
**🎯 Week 6 末上线 MVP**——团队真的可以用了:登录、看仪表盘、与 TPS 对话策划选题、看到有依据的策划报告、聊定后一键生成报题单 docx 下载。
|
||||||
|
|
||||||
**收尾**:写 `logs/phase4a_log.md`,打 `phase4a-mvp-launch` tag。**这是项目第一次"对内发布",务必郑重打 tag**。
|
**收尾**:写 `logs/phase4a_log.md`,打 `phase4a-mvp-launch` tag。**这是项目第一次"对内发布",务必郑重打 tag**。
|
||||||
|
|
||||||
@@ -486,7 +490,8 @@ tps-dashboard/ ← Git 仓库根
|
|||||||
| v1 | 2026-04 | 初版 |
|
| v1 | 2026-04 | 初版 |
|
||||||
| v2 | 2026-05-04 | 精简技术栈、Phase 拆分(避免 Phase 4 大爆炸)、明确每周里程碑 |
|
| v2 | 2026-05-04 | 精简技术栈、Phase 拆分(避免 Phase 4 大爆炸)、明确每周里程碑 |
|
||||||
| v3 | 2026-05-06 | 加 Git + Gitea 工作流;Phase 0 重排(纳入 Git);Phase 2 起 Plan 切 MiniMax M2.7;明确单位机重启路径(路径 B 推荐);新增 docs/ 目录与 phase{N} tag 收尾仪式 |
|
| v3 | 2026-05-06 | 加 Git + Gitea 工作流;Phase 0 重排(纳入 Git);Phase 2 起 Plan 切 MiniMax M2.7;明确单位机重启路径(路径 B 推荐);新增 docs/ 目录与 phase{N} tag 收尾仪式 |
|
||||||
| **v4** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** |
|
| v4 | 2026-05-14 | 家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录 |
|
||||||
|
| **v5** | **2026-05-27** | **Phase 4a 升级为"对话式 TPS"(对齐 project_plan v5):模块 A/C/G 合成左对话右报告形态;补硬门槛(数据灌入 + Phase 3 朴素检索先行);加 AI 成本机制落地说明** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# backlog.md — TPS 工作台待办池(不丢视野,非排期)
|
||||||
|
|
||||||
|
> 维护人:刘通 + 顾问 Opus
|
||||||
|
> 用途:记录"暂不做但不能漏"的事项。区别于 phase 计划,这里是池子,
|
||||||
|
> 每个 Phase 收尾时回看一遍,决定捞哪条进下个 Phase。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. 定位澄清(认知,非待办)
|
||||||
|
|
||||||
|
- 本系统对外称"中台/工作台",实质是**单栏目业务系统**(一条业务线、8 用户)。
|
||||||
|
- 不套真中台那套(多租户/服务网格/配置中心/跨线统一权限)——对 8 人是纯负担。
|
||||||
|
- 真正定位:把散在 Excel/Word/微信/个人电脑/师徒口传的工作流,收拢成
|
||||||
|
有沉淀、有权限、可检索的系统。核心价值 = 经验从"靠人"变"靠系统"。
|
||||||
|
- 现有克制选择(不用 TS / 不用 Alembic / pgvector 不用独立向量库 /
|
||||||
|
Session 不用 JWT)方向正确,保持。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. 工作台核心能力缺口(按优先级)
|
||||||
|
|
||||||
|
### B1. 选题状态流转【优先级:高】
|
||||||
|
- 现状:episodes 存收视+元数据,但"提报→立项→排期→拍摄→制作→播出→复盘"
|
||||||
|
这条状态线是散的。
|
||||||
|
- 建议:给 topics/episodes 加状态机字段,制片人一眼看到每个选题卡在哪环。
|
||||||
|
- 与模块 F 甘特图区别:甘特是排期视角,这是业务状态视角,两回事。
|
||||||
|
- 触发时机:Phase 4b(TPS 闭环时一并补最自然)。
|
||||||
|
|
||||||
|
### B2. 操作留痕 / 审计【优先级:高,成本极低,建议早埋】
|
||||||
|
- 现状:谁改收视、谁删选题、谁换题图,全靠信任,无记录。
|
||||||
|
- 建议:核心业务表(episodes/topics/yearly_targets)加
|
||||||
|
created_by / updated_by / updated_at 字段即可,不必做复杂日志系统。
|
||||||
|
- 触发时机:越早埋越好,建议 Phase 3 起手时顺带加(改表成本最低)。
|
||||||
|
|
||||||
|
### B3. 数据导出 / 备份【优先级:中】
|
||||||
|
- 现状:Phase 2 做了 Excel 导入,导出是它的镜像,迟早要。
|
||||||
|
- 收视、选题是栏目资产,将来要能导出 Excel、能定期备份。
|
||||||
|
- 触发时机:Phase 5 上线前后。
|
||||||
|
|
||||||
|
### B4. 通知 / 待办提醒【优先级:低】
|
||||||
|
- "选题被审了""该交稿了"这类。小团队可能微信/Mattermost 群就解决了。
|
||||||
|
|
||||||
|
- 若 B 见下方与 Mattermost 打通,可借 IM 发通知,不必自建。
|
||||||
|
|
||||||
|
- 触发时机:看实际使用反馈再定,可能不做。
|
||||||
|
|
||||||
|
### B5.关联【优先级:低】
|
||||||
|
|
||||||
|
题图"自动算近5期最高份额提示"本期做的是静态文案占位, 自动计算挪二期(原计划)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. 跨系统整合(与现有其他系统合服务器、打通)
|
||||||
|
|
||||||
|
### C1. 蓝皓配音 TTS 2.0 合服务器【待商议】
|
||||||
|
- 背景:TTS 2.0 现为独立网站/服务器,单租服务器费钱。
|
||||||
|
- 设想:与 TPS 中台挪到同一台服务器,省一份租金。
|
||||||
|
- 待办:制片人把 TTS 2.0 源代码给 Opus → 一起评估
|
||||||
|
①技术栈是否冲突(端口/依赖/数据库)②Nginx 反代能否共存
|
||||||
|
③资源够不够(4核8G 轻量服务器跑两套+PG+pgvector 要算内存账)。
|
||||||
|
- 注:dev_plan 部署章已写"Nginx 反代整合 TPS / 蓝皓 TTS / MMIM",本条是落实。
|
||||||
|
- 触发时机:Phase 5 部署规划时。
|
||||||
|
|
||||||
|
### C2. Mattermost(内部 IM) 打通【待商议】
|
||||||
|
- 背景:团队内部 IM 用 Mattermost,部署在与 TTS 2.0 同一服务器。
|
||||||
|
- 设想:TPS 的通知/待办(见 B4)可推到 Mattermost,不必自建通知系统。
|
||||||
|
- 待办:评估 Mattermost incoming webhook 接入成本(通常很低)。
|
||||||
|
- 触发时机:B4 真要做时一并考虑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. 已知技术隐患(从 phase log 汇集,不漏)
|
||||||
|
|
||||||
|
- backend/ 补 venv(全局依赖搬进去)。
|
||||||
|
- 隐患 A:ImportError 与 Python 内置同名(低优)。
|
||||||
|
- 隐患 B:ExcelService 返回 Pydantic 化(低优)。
|
||||||
|
- Phase 5 部署安全:SECRET_KEY 换真随机、same_site=strict + https_only=True、
|
||||||
|
上 SSL、关 /docs。
|
||||||
|
- Phase 5 上线前数据清理:清测试数据(期次 8888/8889、年度目标 2027/2028
|
||||||
|
占位等),录真数据。
|
||||||
|
- logo 路径用 /src/assets/...(Vite开发模式),生产打包会失效,Phase5部署改。 - reset_password.py 能改任意用户密码,Phase5部署前不能暴露在生产环境。 - 柱图设计为取最近12期,当前真实数据仅7期故显7根;Phase5录真数据后自动补齐,届时确认满12根正常。 - 完成率用 new Date().getFullYear() 取当前年:每年初需录入当年 yearly_targets, 否则跨年后两项完成率显"—"。(运营提醒,非bug)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. 明确不做(防止被"中台"概念诱导加功能)
|
||||||
|
|
||||||
|
- 多租户、服务网格、配置中心、跨业务线统一权限平台 —— 8 人单栏目纯负担。
|
||||||
|
- 任何让 AI 替编导拍板的功能 —— 违背"AI 给方向不给答案"。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
.kt-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kt-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kt-tree-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kt-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 120px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 节点文字 */
|
||||||
|
.kt-node-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #3b4a3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全部根节点高亮 */
|
||||||
|
.kt-node-root {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b4a3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree 选中态 */
|
||||||
|
.kt-container .ant-tree-node-selected .kt-node-title {
|
||||||
|
color: #6b8e6b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 知识库树形导航组件(交互层)
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 树的展开/收起/记忆状态
|
||||||
|
* - 节点高亮 + 联动回调
|
||||||
|
* - 「全部展开 / 全部收起」操作按钮
|
||||||
|
*
|
||||||
|
* 数据分组逻辑完全委托给后端 get_grouped_items(数据分组层),
|
||||||
|
* 本组件只管交互,不感知分组的业务含义。
|
||||||
|
*
|
||||||
|
* 二级节点 key 格式(由后端控制):
|
||||||
|
* - 大类节点: "manuscript"
|
||||||
|
* - 二级节点(新格式): "manuscript|author|左鑫"
|
||||||
|
* type|二级字段名|字段值
|
||||||
|
* - 二级节点(出处,杂志文章): "military_report|source_detail|航空知识..."
|
||||||
|
*
|
||||||
|
* 回调给 KnowledgeBase:统一返回 { type, detail }
|
||||||
|
* - 大类节点 → { type: "manuscript", detail: null }
|
||||||
|
* - 作者二级节点 → { type: "manuscript", detail: "左鑫" }
|
||||||
|
* - 出处二级节点 → { type: "military_report", detail: "航空知识..." }
|
||||||
|
* (detail=author名 或 source_detail原文,与 displayedItems 过滤逻辑对齐)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { Tree, Button, Space } from 'antd'
|
||||||
|
import { CaretDownFilled, NodeIndexOutlined } from '@ant-design/icons'
|
||||||
|
import './KnowledgeTree.css'
|
||||||
|
|
||||||
|
const { TreeNode } = Tree
|
||||||
|
|
||||||
|
export default function KnowledgeTree({
|
||||||
|
treeData, // 来自 getGroupedItems() API 的原始树数据(含全部节点)
|
||||||
|
onNodeSelect, // 选中节点时回调,参数: { key, type, detail } | null(全部)
|
||||||
|
selectedKey, // 当前选中的 key(用于高亮)
|
||||||
|
}) {
|
||||||
|
const [expandedKeys, setExpandedKeys] = useState(() => {
|
||||||
|
// 默认全部展开
|
||||||
|
if (!treeData || treeData.length === 0) return []
|
||||||
|
const root = treeData[0]
|
||||||
|
return [root.key, ...(root.children || []).map(c => c.key)]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全部展开
|
||||||
|
const handleExpandAll = useCallback(() => {
|
||||||
|
if (!treeData || treeData.length === 0) return
|
||||||
|
const root = treeData[0]
|
||||||
|
const allKeys = [root.key]
|
||||||
|
for (const child of root.children || []) {
|
||||||
|
allKeys.push(child.key)
|
||||||
|
if (child.children) {
|
||||||
|
for (const grandchild of child.children) {
|
||||||
|
allKeys.push(grandchild.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpandedKeys(allKeys)
|
||||||
|
}, [treeData])
|
||||||
|
|
||||||
|
// 全部收起
|
||||||
|
const handleCollapseAll = useCallback(() => {
|
||||||
|
setExpandedKeys([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 节点选择
|
||||||
|
const handleSelect = useCallback((selectedKeys, info) => {
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
onNodeSelect(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = selectedKeys[0]
|
||||||
|
|
||||||
|
// 解析 key 格式:
|
||||||
|
// "all" → 全部根节点
|
||||||
|
// "manuscript" → 大类节点
|
||||||
|
// "manuscript|author|左鑫" → 二级节点(新格式:type|fieldName|fieldValue)
|
||||||
|
// "military_report|source_detail|..." → 二级节点(出处)
|
||||||
|
if (key === 'all') {
|
||||||
|
onNodeSelect(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = key.split('|')
|
||||||
|
if (parts.length === 1) {
|
||||||
|
// 大类节点
|
||||||
|
onNodeSelect({ key, type: parts[0], detail: null })
|
||||||
|
} else {
|
||||||
|
// 二级节点:parts[0]=type, parts[1]=字段名(废弃不用), parts[2]=字段值
|
||||||
|
// 统一以 parts[2] 作为 detail(作者名 或 出处名),与 displayedItems 过滤逻辑对齐
|
||||||
|
onNodeSelect({ key, type: parts[0], detail: parts[2] })
|
||||||
|
}
|
||||||
|
}, [onNodeSelect])
|
||||||
|
|
||||||
|
// 渲染节点
|
||||||
|
const renderNode = (node, isRoot = false) => {
|
||||||
|
return (
|
||||||
|
<TreeNode
|
||||||
|
key={node.key}
|
||||||
|
title={
|
||||||
|
<span className={`kt-node-title ${isRoot ? 'kt-node-root' : ''}`}>
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
icon={isRoot ? <NodeIndexOutlined /> : undefined}
|
||||||
|
>
|
||||||
|
{(node.children || []).map(child => renderNode(child, false))}
|
||||||
|
</TreeNode>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!treeData || treeData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="kt-empty">
|
||||||
|
暂无知识库条目
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kt-container">
|
||||||
|
<div className="kt-header">
|
||||||
|
<Space size="small">
|
||||||
|
<Button size="small" onClick={handleExpandAll}>
|
||||||
|
全部展开
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={handleCollapseAll}>
|
||||||
|
全部收起
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div className="kt-tree-wrapper">
|
||||||
|
<Tree
|
||||||
|
showIcon
|
||||||
|
showLine={{ showLeafIcon: false }}
|
||||||
|
expandedKeys={expandedKeys}
|
||||||
|
selectedKeys={selectedKey ? [selectedKey] : []}
|
||||||
|
onExpand={(keys) => setExpandedKeys(keys)}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
blockNode
|
||||||
|
switcherIcon={<CaretDownFilled />}
|
||||||
|
>
|
||||||
|
{treeData.map(node => renderNode(node, true))}
|
||||||
|
</Tree>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,4 +29,6 @@
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: var(--color-bg-cream);
|
background: var(--color-bg-cream);
|
||||||
min-height: calc(100vh - 64px);
|
min-height: calc(100vh - 64px);
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@@ -36,3 +36,27 @@
|
|||||||
background: var(--color-accent-green) !important;
|
background: var(--color-accent-green) !important;
|
||||||
color: var(--color-primary-green) !important;
|
color: var(--color-primary-green) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* disabled 项整体灰化 + 不可点击 */
|
||||||
|
.side-nav-menu .ant-menu-item-disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 即将上线小标签 */
|
||||||
|
.menu-soon-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #999;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* disabled 项内标签不受 opacity 影响 */
|
||||||
|
.side-nav-menu .ant-menu-item-disabled .menu-soon-tag {
|
||||||
|
opacity: 1;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
SoundOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import useAuthStore from '../../stores/authStore'
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
|
||||||
@@ -23,6 +24,28 @@ function SideNav() {
|
|||||||
{ key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' },
|
{ key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' },
|
||||||
{ key: '/editor-home', icon: <UserOutlined />, label: '个人首页' },
|
{ key: '/editor-home', icon: <UserOutlined />, label: '个人首页' },
|
||||||
{ key: '/users', icon: <TeamOutlined />, label: '用户管理' },
|
{ key: '/users', icon: <TeamOutlined />, label: '用户管理' },
|
||||||
|
{
|
||||||
|
key: 'tts-placeholder',
|
||||||
|
icon: <SoundOutlined />,
|
||||||
|
label: (
|
||||||
|
<span className="menu-item-with-tag">
|
||||||
|
蓝皓配音 TTS 2.0
|
||||||
|
<span className="menu-soon-tag">即将上线</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'collab-placeholder',
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
label: (
|
||||||
|
<span className="menu-item-with-tag">
|
||||||
|
内部协作(Mattermost)
|
||||||
|
<span className="menu-soon-tag">即将上线</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 按角色过滤菜单项
|
// 按角色过滤菜单项
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 知识库数据分组层(交互层与分组维度完全解耦)
|
||||||
|
*
|
||||||
|
* 本模块职责:
|
||||||
|
* - 接收 items 数组,按指定维度生成分组树数据
|
||||||
|
* - 完全不涉及 UI(无 React 组件、无 Ant Design 引用)
|
||||||
|
*
|
||||||
|
* 将来切换为「按主题/装备分组」时:
|
||||||
|
* - 只改本文件中的 groupItemsBySource 函数
|
||||||
|
* - 前端 KnowledgeTree 组件一行都不用动
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================
|
||||||
|
* 📌 二级分组维度映射表(核心配置,一处可改)
|
||||||
|
* ============================================================
|
||||||
|
* key = source_type,value = 用来做二级分组的字段名(取 items 里的字段)
|
||||||
|
* null = 该大类不建二级节点,条目直接挂在大类下
|
||||||
|
*
|
||||||
|
* 当前配置:
|
||||||
|
* - manuscript(节目文稿) → 按 author(作者/编导)归堆
|
||||||
|
* - military_report(杂志文章)→ 按 source_detail(出处)归堆
|
||||||
|
* - baoti(报题单) → 不分组
|
||||||
|
* - manual(其他) → 不分组
|
||||||
|
*
|
||||||
|
* 将来调整某类的二级维度,只需改这一张表,本文件分组逻辑全部收敛。
|
||||||
|
*/
|
||||||
|
const SECONDARY_GROUP_FIELD = {
|
||||||
|
manuscript: 'author', // 节目文稿 → 按作者(编导)
|
||||||
|
military_report: 'source_detail', // 杂志文章 → 按出处
|
||||||
|
baoti: null, // 报题单 → 不分组
|
||||||
|
manual: null, // 其他 → 不分组
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================
|
||||||
|
* 📌 来源大类固定显示顺序(制片人 Obsidian 习惯)
|
||||||
|
* ============================================================
|
||||||
|
* 即使某类暂时为空也按此顺序; 有数据时即按此次序排列。
|
||||||
|
* 已有类别按此次序排,未知类别兜底排末尾。
|
||||||
|
*/
|
||||||
|
const SOURCE_TYPE_ORDER = [
|
||||||
|
'manuscript', // 节目文稿
|
||||||
|
'military_report', // 杂志文章
|
||||||
|
'baoti', // 报题单
|
||||||
|
'manual', // 其他
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* source_type 中文标签
|
||||||
|
*/
|
||||||
|
const SOURCE_TYPE_LABEL = {
|
||||||
|
military_report: '杂志文章',
|
||||||
|
manuscript: '节目文稿',
|
||||||
|
baoti: '报题单',
|
||||||
|
manual: '其他',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按来源(source_type → 二级字段)分组
|
||||||
|
* @param {Array} items - 知识库条目数组,含 source_type 字段,及 SECONDARY_GROUP_FIELD 中指定的二级字段
|
||||||
|
* @returns {Array} 树形结构数据,供 KnowledgeTree 组件渲染
|
||||||
|
*
|
||||||
|
* 返回格式:
|
||||||
|
* [
|
||||||
|
* {
|
||||||
|
* key: "all",
|
||||||
|
* label: "全部(N条)",
|
||||||
|
* count: 总条目数,
|
||||||
|
* children: [
|
||||||
|
* {
|
||||||
|
* key: "manuscript",
|
||||||
|
* label: "节目文稿(n条)",
|
||||||
|
* count: n,
|
||||||
|
* children: [ // SECONDARY_GROUP_FIELD[st] 为 null 时为空数组
|
||||||
|
* { key: "manuscript|author|左鑫", label: "左鑫(3条)", count: 3 }
|
||||||
|
* ]
|
||||||
|
* },
|
||||||
|
* ...
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
*
|
||||||
|
* 二级节点 key 格式:`{source_type}|{二级字段名}|{字段值}`
|
||||||
|
* 例:manuscript|author|左鑫
|
||||||
|
* military_report|source_detail|航空知识 2026年第1期
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* - 二级字段值为 null / 空字串 → 归入对应大类,不造空节点
|
||||||
|
* - 无效的 source_type → 归入 'manual' 大类
|
||||||
|
*/
|
||||||
|
export function groupItemsBySource(items) {
|
||||||
|
const totalCount = items.length
|
||||||
|
|
||||||
|
// 按 source_type 分组(按固定顺序遍历)
|
||||||
|
const typeGroups = {}
|
||||||
|
// 初始化所有已知类别,确保空类别也出现在树中
|
||||||
|
for (const st of SOURCE_TYPE_ORDER) {
|
||||||
|
typeGroups[st] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const st = item.source_type || 'manual'
|
||||||
|
// 未知类别兜底(如 'journal' 等新加类型)
|
||||||
|
if (!typeGroups[st]) {
|
||||||
|
typeGroups[st] = []
|
||||||
|
}
|
||||||
|
typeGroups[st].push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = []
|
||||||
|
|
||||||
|
// 按固定顺序遍历 SOURCE_TYPE_ORDER
|
||||||
|
for (const st of SOURCE_TYPE_ORDER) {
|
||||||
|
const stItems = typeGroups[st] || []
|
||||||
|
if (stItems.length === 0) {
|
||||||
|
// 空类别也按顺序出现在树中(0条)
|
||||||
|
children.push({
|
||||||
|
key: st,
|
||||||
|
label: `${SOURCE_TYPE_LABEL[st] || st}(0条)`,
|
||||||
|
count: 0,
|
||||||
|
children: [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondaryField = SECONDARY_GROUP_FIELD[st] || null
|
||||||
|
const grandchildren = []
|
||||||
|
|
||||||
|
if (secondaryField !== null) {
|
||||||
|
// 按二级字段分组
|
||||||
|
const detailGroups = {}
|
||||||
|
for (const item of stItems) {
|
||||||
|
// 取二级字段值,null/空字串统一为 null 处理(不造空节点)
|
||||||
|
const sd = (item[secondaryField] || '').trim() || null
|
||||||
|
if (!detailGroups[sd]) {
|
||||||
|
detailGroups[sd] = []
|
||||||
|
}
|
||||||
|
detailGroups[sd].push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sd, sdItems] of Object.entries(detailGroups)) {
|
||||||
|
if (sd !== null) {
|
||||||
|
// 二级节点 key 格式:type|fieldName|fieldValue
|
||||||
|
grandchildren.push({
|
||||||
|
key: `${st}|${secondaryField}|${sd}`,
|
||||||
|
label: `${sd}(${sdItems.length}条)`,
|
||||||
|
count: sdItems.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// secondaryField 为 null 时,grandchildren 保持为空数组 → 大类直接可点击
|
||||||
|
|
||||||
|
children.push({
|
||||||
|
key: st,
|
||||||
|
label: `${SOURCE_TYPE_LABEL[st] || st}(${stItems.length}条)`,
|
||||||
|
count: stItems.length,
|
||||||
|
children: grandchildren,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: `全部(${totalCount}条)`,
|
||||||
|
count: totalCount,
|
||||||
|
children,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Modal, Upload, Select, Button, message } from 'antd'
|
||||||
|
import { UploadOutlined, InboxOutlined } from '@ant-design/icons'
|
||||||
|
import { uploadCover } from '../../services/dashboardService'
|
||||||
|
|
||||||
|
const { Dragger } = Upload
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更换题图 Modal
|
||||||
|
*
|
||||||
|
* 权限:仅 zhipianren / zebian 可用(由父组件控制显示/隐藏)
|
||||||
|
*
|
||||||
|
* @param {boolean} open - Modal 是否显示
|
||||||
|
* @param {Function} onClose - 关闭回调
|
||||||
|
* @param {Function} onUploaded - 上传成功回调,传入新题图对象
|
||||||
|
* @param {Array} episodes - 期次列表(用于下拉选择关联期次)
|
||||||
|
*/
|
||||||
|
function ChangeCoverModal({ open, onClose, onUploaded, episodes = [] }) {
|
||||||
|
const [file, setFile] = useState(null)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState(null)
|
||||||
|
const [selectedEpisode, setSelectedEpisode] = useState(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
|
// 每次打开时重置状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFile(null)
|
||||||
|
setPreviewUrl(null)
|
||||||
|
setSelectedEpisode(null)
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// 选择文件后生成预览
|
||||||
|
const handleFileChange = (info) => {
|
||||||
|
const rawFile = info.file.originFileObj || info.file
|
||||||
|
if (!rawFile) return
|
||||||
|
setFile(rawFile)
|
||||||
|
const url = URL.createObjectURL(rawFile)
|
||||||
|
setPreviewUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file) {
|
||||||
|
message.warning('请先选择图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedEpisode) {
|
||||||
|
message.warning('请选择关联期次')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const ep = episodes.find(e => String(e.episode_number) === String(selectedEpisode))
|
||||||
|
const result = await uploadCover(file, Number(selectedEpisode), ep?.program_name || `第${selectedEpisode}期`)
|
||||||
|
message.success('题图上传成功')
|
||||||
|
onUploaded({
|
||||||
|
cover_path: result.cover_path,
|
||||||
|
episode_number: Number(selectedEpisode),
|
||||||
|
episode_title: ep?.program_name || `第${selectedEpisode}期`,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err?.response?.data?.detail || '上传失败,请重试'
|
||||||
|
message.error(errMsg)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeOptions = episodes.map(ep => ({
|
||||||
|
value: String(ep.episode_number),
|
||||||
|
label: `第 ${ep.episode_number} 期 · ${ep.program_name}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="更换题图"
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={480}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
{/* 图片上传区域 */}
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 13, color: '#6b6b6b', marginBottom: 8 }}>上传海报图片(PNG/JPG/WEBP,不超过 5MB)</p>
|
||||||
|
<Dragger
|
||||||
|
accept=".png,.jpg,.jpeg,.webp"
|
||||||
|
maxCount={1}
|
||||||
|
beforeUpload={() => false}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
showUploadList={false}
|
||||||
|
style={{ borderRadius: 12 }}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<div style={{ padding: '8px 0' }}>
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="预览"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
<p style={{ marginTop: 8, color: '#6b8e6b', fontSize: 13 }}>点击或拖拽更换图片</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="ant-upload-drag-icon" style={{ marginBottom: 8 }}>
|
||||||
|
<InboxOutlined style={{ fontSize: 32, color: '#6b8e6b' }} />
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#6b6b6b', fontSize: 13 }}>点击或拖拽图片到此处上传</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dragger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 关联期次下拉 */}
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 13, color: '#6b6b6b', marginBottom: 8 }}>关联期次</p>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择期次"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={selectedEpisode}
|
||||||
|
onChange={setSelectedEpisode}
|
||||||
|
options={episodeOptions}
|
||||||
|
showSearch
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
option.label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={onClose} disabled={uploading}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
loading={uploading}
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!file || !selectedEpisode}
|
||||||
|
>
|
||||||
|
确认上传
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangeCoverModal
|
||||||
@@ -1,149 +1,173 @@
|
|||||||
.dashboard {
|
.dashboard {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Banner */
|
/* ===== Banner ===== */
|
||||||
.dashboard-banner {
|
.dashboard-banner {
|
||||||
background: #fff;
|
width: 100%;
|
||||||
border-radius: var(--radius-card);
|
height: 150px;
|
||||||
padding: 24px;
|
border-radius: 14px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-text h2 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 20px;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-text p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-image-placeholder {
|
|
||||||
width: 200px;
|
|
||||||
height: 100px;
|
|
||||||
background: var(--color-accent-green);
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--color-primary-green);
|
|
||||||
font-size: 12px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: #3a4a3a;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-placeholder-gradient {
|
.banner-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(135deg, #6b8e6b 0%, #a8c89a 50%, #d4e6b5 100%);
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-gradient {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
rgba(12, 16, 12, 0.86) 0%,
|
||||||
|
rgba(12, 16, 12, 0.6) 35%,
|
||||||
|
rgba(12, 16, 12, 0.08) 70%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-logo {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
height: 64px;
|
||||||
|
width: auto;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #cdd8c8;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #d6e0d0;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #bcc8b6;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-cover-btn {
|
.change-cover-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 6px;
|
bottom: 16px;
|
||||||
right: 6px;
|
right: 16px;
|
||||||
z-index: 2;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-label {
|
/* 默认渐变底图(无题图时) */
|
||||||
|
.banner-default-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
inset: 0;
|
||||||
left: 50%;
|
background: linear-gradient(135deg, #3a4a3a 0%, #5a7a5a 50%, #7a9a6a 100%);
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 2;
|
|
||||||
color: rgba(255,255,255,0.85);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* KPI Cards */
|
/* ===== KPI Strip ===== */
|
||||||
.kpi-row {
|
.kpi-strip {
|
||||||
margin-bottom: 24px;
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-card {
|
.kpi-chip {
|
||||||
border-radius: var(--radius-card);
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
height: 48px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-icon {
|
.kpi-icon {
|
||||||
width: 56px;
|
width: 28px;
|
||||||
height: 56px;
|
height: 28px;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
|
||||||
.kpi-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-value {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-label {
|
.kpi-label {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--color-text-secondary);
|
color: #6b6b6b;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content Cards */
|
.kpi-value {
|
||||||
.cards-row {
|
font-size: 15px;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-card {
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-card .ant-card-head-title {
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-text {
|
/* ===== Bar Chart Card ===== */
|
||||||
|
.bar-chart-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
margin-bottom: 12px;
|
||||||
padding: 32px 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-text p {
|
.bar-chart-title {
|
||||||
margin: 8px 0 4px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: #3b4a3b;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-text small {
|
.bar-chart-hint {
|
||||||
color: #999;
|
font-size: 11px;
|
||||||
}
|
color: #888780;
|
||||||
|
|
||||||
/* Chart Container */
|
|
||||||
.chart-container {
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bars-wrapper {
|
.bars-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
height: 200px;
|
height: 180px;
|
||||||
padding: 0 8px;
|
padding: 0 4px;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-item {
|
.bar-item {
|
||||||
@@ -151,41 +175,40 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 40px;
|
max-width: 42px;
|
||||||
|
min-width: 20px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
width: 24px;
|
width: 100%;
|
||||||
border-radius: 4px 4px 0 0;
|
max-width: 38px;
|
||||||
|
border-radius: 7px 7px 0 0;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-label-top {
|
.bar-number {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--color-text-secondary);
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 4px;
|
white-space: nowrap;
|
||||||
max-width: 40px;
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-program {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #6b6b6b;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 64px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
|
||||||
|
|
||||||
.bar-label-bottom {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-label-bottom span {
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chart Legend */
|
|
||||||
.chart-legend {
|
.chart-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -200,7 +223,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--color-text-secondary);
|
color: #6b6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
@@ -208,3 +231,125 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Bottom Two Columns ===== */
|
||||||
|
.bottom-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3b4a3b;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radar placeholder card */
|
||||||
|
.radar-placeholder {
|
||||||
|
border: 1.5px dashed #c8d9b0;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-placeholder-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #bcc8b6;
|
||||||
|
background: #fbf9f1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-placeholder p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b6b6b;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-placeholder small {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-sample-items {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-sample-item {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4a5a4a;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #f0f7ec;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schedule list */
|
||||||
|
.schedule-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-ep {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-name {
|
||||||
|
color: #4a5a4a;
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-date {
|
||||||
|
color: #888780;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-editor {
|
||||||
|
color: #6b6b6b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.chart-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 180px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Row, Col, Card, Avatar, Tooltip, Button } from 'antd'
|
import { Row, Col, Card, Avatar, Tooltip, Button, Spin } from 'antd'
|
||||||
import {
|
import {
|
||||||
BarChartOutlined,
|
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
CalendarOutlined,
|
|
||||||
FireOutlined,
|
FireOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
|
AimOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import useAuthStore from '../../stores/authStore'
|
import useAuthStore from '../../stores/authStore'
|
||||||
import { listEpisodes } from '../../services/episodeService'
|
import { listEpisodes } from '../../services/episodeService'
|
||||||
import { listTargets } from '../../services/yearlyTargetService'
|
import { listTargets } from '../../services/yearlyTargetService'
|
||||||
|
import { getCurrentCover, getUpcomingSchedules } from '../../services/dashboardService'
|
||||||
|
import ChangeCoverModal from './ChangeCoverModal'
|
||||||
import './Dashboard.css'
|
import './Dashboard.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,9 +25,8 @@ import './Dashboard.css'
|
|||||||
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
|
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
|
||||||
*/
|
*/
|
||||||
function getShareColor(share, targets, airYear) {
|
function getShareColor(share, targets, airYear) {
|
||||||
// 强制转数值,避免后端返回字符串导致数值比较失败
|
|
||||||
const n = Number(share)
|
const n = Number(share)
|
||||||
const target = targets.find(t => Number(t.year) === Number(airYear))
|
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
|
||||||
if (!target) return '#999'
|
if (!target) return '#999'
|
||||||
if (isNaN(n)) return '#999'
|
if (isNaN(n)) return '#999'
|
||||||
const stretch = Number(target.stretch_target)
|
const stretch = Number(target.stretch_target)
|
||||||
@@ -32,140 +36,227 @@ function getShareColor(share, targets, airYear) {
|
|||||||
return '#7aa874' // 绿=未达基础目标(待提升)
|
return '#7aa874' // 绿=未达基础目标(待提升)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getColorLabel(share, targets, airYear) {
|
||||||
|
const n = Number(share)
|
||||||
|
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
|
||||||
|
if (!target) return '未知'
|
||||||
|
const stretch = Number(target.stretch_target)
|
||||||
|
const base = Number(target.base_target)
|
||||||
|
if (n > stretch) return '优秀'
|
||||||
|
if (n >= base) return '达标'
|
||||||
|
return '待提升'
|
||||||
|
}
|
||||||
|
|
||||||
function getBarHeight(share) {
|
function getBarHeight(share) {
|
||||||
return Math.round((share / 1.0) * 180)
|
return Math.round((Number(share) / 1.0) * 160)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getShortTitle(title) {
|
function getShortTitle(title) {
|
||||||
return title.length > 12 ? title.slice(0, 12) + '...' : title
|
if (!title) return '?'
|
||||||
|
return title.length > 9 ? title.slice(0, 9) + '...' : title
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算当年完成率(纯前端,量纲已确认为 0~1 小数直接相除)
|
||||||
|
* - "当年"= 当前自然年(new Date().getFullYear())
|
||||||
|
* - avg(audience_share) 当年已录期 ÷ base/stretch_target
|
||||||
|
* - 无数据时始终返回 { base: '—', stretch: '—' }
|
||||||
|
* - 完成率可 >100% 正常显示
|
||||||
|
*/
|
||||||
|
function calcCompletionRate(episodes, targets) {
|
||||||
|
const yearInt = new Date().getFullYear()
|
||||||
|
const yearEpisodes = episodes.filter(e => {
|
||||||
|
const airYear = new Date(e.air_date).getFullYear()
|
||||||
|
return airYear === yearInt && e.audience_share != null
|
||||||
|
})
|
||||||
|
const target = (targets || []).find(t => Number(t.year) === yearInt)
|
||||||
|
|
||||||
|
if (!yearEpisodes.length || !target) return { base: '—', stretch: '—' }
|
||||||
|
|
||||||
|
const avgShare = yearEpisodes.reduce((sum, e) => sum + Number(e.audience_share), 0) / yearEpisodes.length
|
||||||
|
const baseRate = avgShare / Number(target.base_target)
|
||||||
|
const stretchRate = avgShare / Number(target.stretch_target)
|
||||||
|
return {
|
||||||
|
base: (baseRate * 100).toFixed(1) + '%',
|
||||||
|
stretch: (stretchRate * 100).toFixed(1) + '%',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const [episodes, setEpisodes] = useState([])
|
const [episodes, setEpisodes] = useState([])
|
||||||
const [targets, setTargets] = useState([])
|
const [targets, setTargets] = useState([])
|
||||||
|
const [cover, setCover] = useState({ cover_path: null, episode_number: null, episode_title: null })
|
||||||
|
const [schedules, setSchedules] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [coverModalOpen, setCoverModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
listEpisodes(9),
|
listEpisodes(50),
|
||||||
listTargets(),
|
listTargets(),
|
||||||
]).then(([epData, tgtData]) => {
|
getCurrentCover(),
|
||||||
// 按 air_date 倒序取前 9
|
getUpcomingSchedules(6),
|
||||||
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date)).slice(0, 9)
|
]).then(([epData, tgtData, coverData, schedData]) => {
|
||||||
|
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date))
|
||||||
setEpisodes(sorted)
|
setEpisodes(sorted)
|
||||||
setTargets(tgtData || [])
|
setTargets(tgtData || [])
|
||||||
|
setCover(coverData || { cover_path: null, episode_number: null, episode_title: null })
|
||||||
|
setSchedules(Array.isArray(schedData) ? schedData : [])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 显示最近有份额数据的期次(最多9个,不够用真实数据补位)
|
// 显示最近有份额数据的期次(最多12个)
|
||||||
const hasShare = episodes.filter(e => e.audience_share != null)
|
const displayEpisodes = episodes
|
||||||
const displayEpisodes = hasShare.length >= 5 ? hasShare.slice(0, 9) : episodes.slice(0, Math.max(9, hasShare.length || 5))
|
.filter(e => e.audience_share != null)
|
||||||
|
.slice(0, 12)
|
||||||
|
|
||||||
const bestEpisode = [...displayEpisodes].filter(e => e.audience_share != null).sort((a, b) => b.audience_share - a.audience_share)[0]
|
const bestEpisode = [...displayEpisodes].sort((a, b) => Number(b.audience_share) - Number(a.audience_share))[0]
|
||||||
|
|
||||||
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian'
|
const handleCoverUploaded = (newCover) => {
|
||||||
|
setCover(newCover)
|
||||||
|
setCoverModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KPI 数据(扩为 5 项:原有 3 项 + 年度完成率 2 项)
|
||||||
|
const completion = calcCompletionRate(episodes, targets)
|
||||||
|
const kpiData = [
|
||||||
|
{
|
||||||
|
icon: <EyeOutlined style={{ color: '#6b8e6b', fontSize: 14 }} />,
|
||||||
|
bg: '#e8f5e9',
|
||||||
|
label: '近12期已录',
|
||||||
|
value: displayEpisodes.length || '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FireOutlined style={{ color: '#ff9800', fontSize: 14 }} />,
|
||||||
|
bg: '#fff3e0',
|
||||||
|
label: '最佳份额',
|
||||||
|
value: bestEpisode ? (Number(bestEpisode.audience_share) * 100).toFixed(2) + '%' : '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CalendarOutlined style={{ color: '#2196f3', fontSize: 14 }} />,
|
||||||
|
bg: '#e3f2fd',
|
||||||
|
label: '年度目标',
|
||||||
|
value: targets.length || '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <LineChartOutlined style={{ color: '#7b5e9e', fontSize: 14 }} />,
|
||||||
|
bg: '#f3e5f5',
|
||||||
|
label: '基础目标完成率',
|
||||||
|
value: completion.base,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <AimOutlined style={{ color: '#c0584f', fontSize: 14 }} />,
|
||||||
|
bg: '#fce4ec',
|
||||||
|
label: '摸高目标完成率',
|
||||||
|
value: completion.stretch,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
{/* 顶部 Banner */}
|
{/* ===== Banner ===== */}
|
||||||
<div className="dashboard-banner">
|
<div className="dashboard-banner">
|
||||||
<div className="banner-text">
|
{/* 题图底图 或 默认渐变 */}
|
||||||
<h2>本月收视最佳</h2>
|
{cover.cover_path ? (
|
||||||
{bestEpisode ? (
|
<img
|
||||||
<p>
|
src={cover.cover_path}
|
||||||
第 {bestEpisode.episode_number} 期 {bestEpisode.program_name} ·
|
alt="题图"
|
||||||
收视份额 {bestEpisode.audience_share}
|
className="banner-bg"
|
||||||
</p>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p>暂无收视数据</p>
|
<div className="banner-default-bg" />
|
||||||
)}
|
)}
|
||||||
|
{/* 暗化渐变层 */}
|
||||||
|
<div className="banner-gradient" />
|
||||||
|
{/* 右上角 Logo */}
|
||||||
|
<img
|
||||||
|
src="/src/assets/junshikeji_logo.png"
|
||||||
|
alt="栏目logo"
|
||||||
|
className="banner-logo"
|
||||||
|
onError={e => { e.target.style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
{/* 左侧文字 */}
|
||||||
|
<div className="banner-text">
|
||||||
|
<p className="banner-eyebrow">本月收视最佳·题图</p>
|
||||||
|
<h2 className="banner-title">
|
||||||
|
{cover.episode_number
|
||||||
|
? `第 ${cover.episode_number} 期`
|
||||||
|
: bestEpisode
|
||||||
|
? `第 ${bestEpisode.episode_number} 期`
|
||||||
|
: '暂无收视数据'}
|
||||||
|
{cover.episode_title || (bestEpisode ? ` · ${bestEpisode.program_name}` : '')}
|
||||||
|
</h2>
|
||||||
|
<p className="banner-subtitle">
|
||||||
|
{bestEpisode
|
||||||
|
? `收视份额 ${Number(bestEpisode.audience_share).toFixed(4)} · 当月最高`
|
||||||
|
: '暂无收视数据'}
|
||||||
|
</p>
|
||||||
|
<p className="banner-hint">建议:可选用近期收视表现好的节目海报</p>
|
||||||
</div>
|
</div>
|
||||||
{/* 题图渐变占位 */}
|
{/* 右下角换图按钮 */}
|
||||||
<div className="banner-image-placeholder">
|
|
||||||
<div className="banner-placeholder-gradient" />
|
|
||||||
{showChangeCoverBtn && (
|
{showChangeCoverBtn && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
|
||||||
icon={<PictureOutlined />}
|
|
||||||
disabled
|
|
||||||
className="change-cover-btn"
|
className="change-cover-btn"
|
||||||
|
icon={<PictureOutlined />}
|
||||||
|
onClick={() => setCoverModalOpen(true)}
|
||||||
>
|
>
|
||||||
更换题图
|
更换题图
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<span className="placeholder-label">敬请期待</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* ===== KPI 细条 ===== */}
|
||||||
<Row gutter={16} className="kpi-row">
|
<div className="kpi-strip">
|
||||||
<Col span={8}>
|
{kpiData.map((item, i) => (
|
||||||
<Card className="kpi-card">
|
<div key={i} className="kpi-chip">
|
||||||
<div className="kpi-icon" style={{ background: '#e8f5e9' }}>
|
<div className="kpi-icon" style={{ background: item.bg }}>
|
||||||
<EyeOutlined style={{ color: '#6b8e6b', fontSize: 24 }} />
|
{item.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="kpi-info">
|
<div>
|
||||||
<span className="kpi-value">{displayEpisodes.filter(e => e.audience_share).length || '--'}</span>
|
<p className="kpi-label">{item.label}</p>
|
||||||
<span className="kpi-label">近 9 期已录</span>
|
<p className="kpi-value">{item.value}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Card className="kpi-card">
|
|
||||||
<div className="kpi-icon" style={{ background: '#fff3e0' }}>
|
|
||||||
<FireOutlined style={{ color: '#ff9800', fontSize: 24 }} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="kpi-info">
|
))}
|
||||||
<span className="kpi-value">
|
|
||||||
{bestEpisode ? (bestEpisode.audience_share * 100).toFixed(1) + '%' : '--'}
|
|
||||||
</span>
|
|
||||||
<span className="kpi-label">最佳收视份额</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Card className="kpi-card">
|
|
||||||
<div className="kpi-icon" style={{ background: '#e3f2fd' }}>
|
|
||||||
<CalendarOutlined style={{ color: '#2196f3', fontSize: 24 }} />
|
|
||||||
</div>
|
|
||||||
<div className="kpi-info">
|
|
||||||
<span className="kpi-value">{targets.length || '--'}</span>
|
|
||||||
<span className="kpi-label">年度目标条数</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{/* 三卡片区域 */}
|
{/* ===== 近9期收视柱图(全宽) ===== */}
|
||||||
<Row gutter={16} className="cards-row">
|
<div className="bar-chart-card">
|
||||||
{/* 热点雷达 */}
|
<div className="bar-chart-header">
|
||||||
<Col span={8}>
|
<h3 className="bar-chart-title">近 12 期收视份额</h3>
|
||||||
<Card className="content-card" title="热点雷达">
|
<span className="bar-chart-hint">悬浮看编导/期次/收视率·颜色只对收视份额</span>
|
||||||
<div className="placeholder-text">
|
|
||||||
<BarChartOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
|
|
||||||
<p>热点雷达 · Phase 4c</p>
|
|
||||||
<small>编导个人首页核心功能,Phase 4c 实施</small>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
{loading ? (
|
||||||
</Col>
|
<div className="chart-loading"><Spin size="small" /> 加载中...</div>
|
||||||
|
) : (
|
||||||
{/* 近 9 期收视柱图 */}
|
<>
|
||||||
<Col span={8}>
|
|
||||||
<Card className="content-card" title="近 9 期收视" loading={loading}>
|
|
||||||
<div className="chart-container">
|
|
||||||
<div className="bars-wrapper">
|
<div className="bars-wrapper">
|
||||||
{displayEpisodes.map((ep) => {
|
{displayEpisodes.map((ep) => {
|
||||||
const airYear = new Date(ep.air_date).getFullYear()
|
const airYear = new Date(ep.air_date).getFullYear()
|
||||||
const color = ep.audience_share != null
|
const color = ep.audience_share != null
|
||||||
? getShareColor(ep.audience_share, targets, airYear)
|
? getShareColor(ep.audience_share, targets, airYear)
|
||||||
: '#ccc'
|
: '#ccc'
|
||||||
|
const colorLabel = ep.audience_share != null
|
||||||
|
? getColorLabel(ep.audience_share, targets, airYear)
|
||||||
|
: '无数据'
|
||||||
|
const tooltipContent = `
|
||||||
|
<strong>第 ${ep.episode_number} 期 · ${ep.program_name}</strong><br/>
|
||||||
|
编导:${ep.editor_name_snapshot || '未知'}<br/>
|
||||||
|
收视份额:${ep.audience_share != null ? Number(ep.audience_share).toFixed(4) : '无数据'}(${colorLabel})<br/>
|
||||||
|
收视率:${ep.audience_rating != null ? Number(ep.audience_rating).toFixed(4) : '无数据'}
|
||||||
|
`
|
||||||
return (
|
return (
|
||||||
<div key={ep.id} className="bar-item">
|
<div key={ep.id} className="bar-item">
|
||||||
<Tooltip title={`${ep.program_name} · ${ep.audience_share ?? '无数据'}`}>
|
<div className="bar-number" style={{ color }}>
|
||||||
|
{ep.audience_share != null ? Number(ep.audience_share).toFixed(2) : '--'}
|
||||||
|
</div>
|
||||||
|
<Tooltip title={<span dangerouslySetInnerHTML={{ __html: tooltipContent }} />}>
|
||||||
<div
|
<div
|
||||||
className="bar"
|
className="bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -174,37 +265,77 @@ function Dashboard() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div className="bar-label-top">{getShortTitle(ep.program_name)}</div>
|
<div className="bar-program" title={ep.program_name}>
|
||||||
<div className="bar-label-bottom">
|
{getShortTitle(ep.program_name)}
|
||||||
<Avatar size={20} style={{ fontSize: 10 }}>
|
|
||||||
{(ep.editor_name_snapshot || '?')[0]}
|
|
||||||
</Avatar>
|
|
||||||
<span>{ep.editor_name_snapshot || '未知'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-legend">
|
<div className="chart-legend">
|
||||||
<span className="legend-item"><span className="dot" style={{ background: '#7aa874' }}></span>绿=未达基础目标(待提升)</span>
|
<span className="legend-item">
|
||||||
<span className="legend-item"><span className="dot" style={{ background: '#5b8db8' }}></span>蓝=未达摸高目标(达标)</span>
|
<span className="dot" style={{ background: '#7aa874' }}></span>绿=未达基础目标(待提升)
|
||||||
<span className="legend-item"><span className="dot" style={{ background: '#c0584f' }}></span>红=超过摸高目标(优秀)</span>
|
</span>
|
||||||
|
<span className="legend-item">
|
||||||
|
<span className="dot" style={{ background: '#5b8db8' }}></span>蓝=未达摸高目标(达标)
|
||||||
|
</span>
|
||||||
|
<span className="legend-item">
|
||||||
|
<span className="dot" style={{ background: '#c0584f' }}></span>红=超过摸高目标(优秀)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
{/* 排播计划 */}
|
{/* ===== 下方两栏 ===== */}
|
||||||
<Col span={8}>
|
<div className="bottom-row">
|
||||||
<Card className="content-card" title="未来排播计划">
|
{/* 左:雷达占位 */}
|
||||||
<div className="placeholder-text">
|
<div className="side-card">
|
||||||
<CalendarOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
|
<h3 className="side-card-title">军事科技热点雷达</h3>
|
||||||
<p>排播计划 · Phase 4b</p>
|
<div className="radar-placeholder">
|
||||||
<small>甘特图排期使用 frappe-gantt 实现,Phase 4b 开发</small>
|
<span className="radar-placeholder-label">Phase 4c·占位</span>
|
||||||
|
<div className="radar-sample-items">
|
||||||
|
<div className="radar-sample-item">1. 神舟飞船对接空间站</div>
|
||||||
|
<div className="radar-sample-item">2. 新型无人机集群技术</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<BarChartOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 4 }} />
|
||||||
</Col>
|
<p>真实抓取 Phase 4c 实现</p>
|
||||||
</Row>
|
<small>编导个人首页核心功能</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右:排播计划 */}
|
||||||
|
<div className="side-card">
|
||||||
|
<h3 className="side-card-title">未来排播计划</h3>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', color: '#999', padding: '24px 0' }}><Spin size="small" /></div>
|
||||||
|
) : schedules.length > 0 ? (
|
||||||
|
<ul className="schedule-list">
|
||||||
|
{schedules.map((item, i) => (
|
||||||
|
<li key={i} className="schedule-item">
|
||||||
|
<span className="schedule-ep">第{item.episode_number}期</span>
|
||||||
|
<span className="schedule-name" title={item.program_name}>{item.program_name}</span>
|
||||||
|
<span className="schedule-date">{item.planned_air_date}</span>
|
||||||
|
<span className="schedule-editor">{item.editor_name_snapshot || ''}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', color: '#999', padding: '24px 0' }}>
|
||||||
|
<CalendarOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 8 }} />
|
||||||
|
<p style={{ margin: 0, fontSize: 12 }}>暂无排播数据</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 换图 Modal */}
|
||||||
|
<ChangeCoverModal
|
||||||
|
open={coverModalOpen}
|
||||||
|
onClose={() => setCoverModalOpen(false)}
|
||||||
|
onUploaded={handleCoverUploaded}
|
||||||
|
episodes={episodes.slice(0, 20)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,423 @@
|
|||||||
import { Card } from 'antd'
|
import { useState, useEffect } from 'react'
|
||||||
import { BookOutlined } from '@ant-design/icons'
|
import { Card, Upload, Table, Button, Space, Select, Popconfirm, message, Tag, Input } from 'antd'
|
||||||
|
import { DeleteOutlined, ReloadOutlined, UploadOutlined, SearchOutlined, ClearOutlined } from '@ant-design/icons'
|
||||||
|
import useAuthStore from '../../stores/authStore'
|
||||||
|
import knowledgeService from '../../services/knowledgeService'
|
||||||
|
import KnowledgeTree from '../../components/KnowledgeTree/KnowledgeTree'
|
||||||
|
|
||||||
function KnowledgeBase() {
|
const { Dragger } = Upload
|
||||||
return (
|
|
||||||
<Card style={{ borderRadius: 16 }}>
|
// source_type 枚举值(固定五类,不写死但供 Select 用)
|
||||||
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}>
|
const SOURCE_TYPE_OPTIONS = [
|
||||||
<BookOutlined style={{ fontSize: 48, marginBottom: 16 }} />
|
{ label: '全部类型', value: '' },
|
||||||
<h3 style={{ color: '#666' }}>知识库</h3>
|
{ label: '杂志文章', value: 'military_report' },
|
||||||
<p>模块 C · Phase 3 实施</p>
|
{ label: '节目文稿', value: 'manuscript' },
|
||||||
<small>往期报题单 / 文稿 / 军报语义检索</small>
|
{ label: '报题单', value: 'baoti' },
|
||||||
</div>
|
]
|
||||||
</Card>
|
|
||||||
)
|
// source_type 中文标签
|
||||||
|
const SOURCE_TYPE_LABEL = {
|
||||||
|
military_report: '杂志文章',
|
||||||
|
manuscript: '节目文稿',
|
||||||
|
baoti: '报题单',
|
||||||
|
manual: '其他',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default KnowledgeBase
|
export default function KnowledgeBase() {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [treeData, setTreeData] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [sources, setSources] = useState([]) // 出处下拉选项
|
||||||
|
const [sourceTypeFilter, setSourceTypeFilter] = useState('')
|
||||||
|
const [sourceDetailFilter, setSourceDetailFilter] = useState('')
|
||||||
|
const [selectedTreeNode, setSelectedTreeNode] = useState(null) // { type, detail }
|
||||||
|
|
||||||
|
// 搜索状态
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState([])
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await knowledgeService.listItems(sourceTypeFilter || null)
|
||||||
|
setItems(data)
|
||||||
|
} catch {
|
||||||
|
message.error('加载知识库失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTree = async () => {
|
||||||
|
try {
|
||||||
|
const data = await knowledgeService.getGroupedItems()
|
||||||
|
setTreeData(data || [])
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSources = async () => {
|
||||||
|
try {
|
||||||
|
const data = await knowledgeService.listSources()
|
||||||
|
// 接口返回 [{source: "航空知识 2026年第1期"}, ...],提取 source 字段
|
||||||
|
setSources(data.map(s => s.source).filter(Boolean))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems()
|
||||||
|
fetchTree()
|
||||||
|
fetchSources()
|
||||||
|
}, [sourceTypeFilter])
|
||||||
|
|
||||||
|
// 上传
|
||||||
|
const handleUpload = async (file) => {
|
||||||
|
if (!file.name.endsWith('.md')) {
|
||||||
|
message.warning('仅支持 .md 文件')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const result = await knowledgeService.uploadFiles([file])
|
||||||
|
const { uploaded, errors } = result
|
||||||
|
if (uploaded.length > 0) {
|
||||||
|
message.success(`成功入库 ${uploaded.length} 篇`)
|
||||||
|
fetchItems()
|
||||||
|
fetchTree()
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
errors.forEach(e => message.error(`${e.file}: ${e.error}`))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('上传失败')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
return false // 阻止默认上传行为
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
await knowledgeService.deleteItem(id)
|
||||||
|
message.success('删除成功')
|
||||||
|
fetchItems()
|
||||||
|
fetchTree()
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索处理
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setSearchResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSearchLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await knowledgeService.searchItems(searchQuery.trim())
|
||||||
|
setSearchResults(data.results || [])
|
||||||
|
} catch {
|
||||||
|
message.error('搜索失败')
|
||||||
|
setSearchResults([])
|
||||||
|
} finally {
|
||||||
|
setSearchLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空搜索 → 恢复树形浏览(不丢树状态)
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearchQuery('')
|
||||||
|
setSearchResults([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 树节点选中 → 联动过滤
|
||||||
|
const handleNodeSelect = (node) => {
|
||||||
|
setSelectedTreeNode(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据树节点过滤列表
|
||||||
|
const displayedItems = (() => {
|
||||||
|
let result = items
|
||||||
|
|
||||||
|
// 树节点过滤优先(覆盖原有 Select 筛选)
|
||||||
|
if (selectedTreeNode) {
|
||||||
|
const { type, detail } = selectedTreeNode
|
||||||
|
result = result.filter(i => {
|
||||||
|
if (i.source_type !== type) return false
|
||||||
|
if (detail !== null) {
|
||||||
|
// 按大类决定用哪个字段比对(节目文稿=author,杂志文章=source_detail)
|
||||||
|
if (type === 'manuscript') {
|
||||||
|
if (i.author !== detail) return false
|
||||||
|
} else {
|
||||||
|
// 杂志文章等用 source_detail
|
||||||
|
if (i.source_detail !== detail) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
} else if (sourceDetailFilter) {
|
||||||
|
// 保留原有的 source_detail 筛选逻辑
|
||||||
|
result = result.filter(i => i.source_detail === sourceDetailFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})()
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
width: 280,
|
||||||
|
render: (text) => (
|
||||||
|
<span style={{ fontWeight: 500, color: '#3b4a3b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{text}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '作者',
|
||||||
|
dataIndex: 'author',
|
||||||
|
key: 'author',
|
||||||
|
width: 100,
|
||||||
|
render: (val) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '播出/发表时间',
|
||||||
|
dataIndex: 'publish_date',
|
||||||
|
key: 'publish_date',
|
||||||
|
width: 160,
|
||||||
|
render: (val) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '出处',
|
||||||
|
dataIndex: 'source_detail',
|
||||||
|
key: 'source_detail',
|
||||||
|
width: 200,
|
||||||
|
render: (val) => val || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'source_type',
|
||||||
|
key: 'source_type',
|
||||||
|
width: 96,
|
||||||
|
render: (val) => (
|
||||||
|
<Tag color="default" style={{ borderRadius: 8 }}>
|
||||||
|
{SOURCE_TYPE_LABEL[val] || val}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 90,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description="删除后不可恢复,该篇及其向量将一并清除"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="确认删除"
|
||||||
|
cancelText="取消"
|
||||||
|
placement="left"
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1200, padding: '12px 16px' }}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: '#3b4a3b' }}>知识库</h2>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#888' }}>上传 md 笔记 · 语义检索 · 报题参考</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上传区(收小为一行按钮) */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||||
|
<Dragger
|
||||||
|
accept=".md"
|
||||||
|
multiple
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} size="middle" style={{ borderRadius: 10 }}>
|
||||||
|
{uploading ? '正在上传并生成向量…' : '上传 md 文件入库'}
|
||||||
|
</Button>
|
||||||
|
</Dragger>
|
||||||
|
<span style={{ fontSize: 12, color: '#aaa' }}>支持批量上传,系统自动解析 yaml frontmatter 并生成语义向量</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索栏(搜索模式/浏览模式互斥,清空恢复树形浏览不丢状态) */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="输入一段文字,语义搜索知识库…"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
style={{ width: 340, borderRadius: 10 }}
|
||||||
|
prefix={<SearchOutlined style={{ color: '#aaa' }} />}
|
||||||
|
suffix={
|
||||||
|
searchQuery ? (
|
||||||
|
<ClearOutlined
|
||||||
|
style={{ color: '#aaa', cursor: 'pointer' }}
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={handleSearch}
|
||||||
|
loading={searchLoading}
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
{searchQuery && (
|
||||||
|
<span style={{ fontSize: 12, color: '#888' }}>
|
||||||
|
{searchResults.length > 0
|
||||||
|
? `找到 ${searchResults.length} 条相关结果`
|
||||||
|
: '无相关结果'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索结果列表(搜索模式显示,浏览模式隐藏) */}
|
||||||
|
{searchQuery && searchResults.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#666', marginBottom: 8, fontWeight: 500 }}>
|
||||||
|
搜索结果
|
||||||
|
</div>
|
||||||
|
{searchResults.map(item => (
|
||||||
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
size="small"
|
||||||
|
style={{ borderRadius: 10, marginBottom: 8 }}
|
||||||
|
bodyStyle={{ padding: '12px 16px' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, color: '#3b4a3b', marginBottom: 4 }}>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<Tag color="default" style={{ borderRadius: 6, fontSize: 12 }}>
|
||||||
|
{SOURCE_TYPE_LABEL[item.source_type] || item.source_type}
|
||||||
|
</Tag>
|
||||||
|
{item.author && (
|
||||||
|
<span style={{ fontSize: 12, color: '#888' }}>作者:{item.author}</span>
|
||||||
|
)}
|
||||||
|
{item.source_detail && (
|
||||||
|
<span style={{ fontSize: 12, color: '#888' }}>出处:{item.source_detail}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#555',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '6px 10px',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.snippet}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
marginLeft: 16,
|
||||||
|
minWidth: 56,
|
||||||
|
textAlign: 'center',
|
||||||
|
background: item.similarity >= 0.7 ? '#e6f4ff' : '#f5f5f5',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '4px 8px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: item.similarity >= 0.7 ? '#1677ff' : '#666' }}>
|
||||||
|
{Math.max(0, Math.round(item.similarity * 100))}%
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#888' }}>相关度</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主体:左侧树 + 右侧列表(flex 左右并列,宽度稳定) */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||||
|
{/* 左侧树 */}
|
||||||
|
<Card
|
||||||
|
style={{ borderRadius: 14, width: 280, flexShrink: 0 }}
|
||||||
|
bodyStyle={{ padding: 0, height: '100%' }}
|
||||||
|
>
|
||||||
|
<KnowledgeTree
|
||||||
|
treeData={treeData}
|
||||||
|
onNodeSelect={handleNodeSelect}
|
||||||
|
selectedKey={selectedTreeNode ? `${selectedTreeNode.type}${selectedTreeNode.detail ? '|' + selectedTreeNode.detail : ''}` : 'all'}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 右侧列表 + 筛选栏 */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* 筛选栏 */}
|
||||||
|
<Card style={{ borderRadius: 14, marginBottom: 12 }}>
|
||||||
|
<Space size="middle" wrap>
|
||||||
|
<span style={{ fontSize: 14, color: '#666' }}>筛选:</span>
|
||||||
|
<Select
|
||||||
|
placeholder="按类型"
|
||||||
|
options={SOURCE_TYPE_OPTIONS}
|
||||||
|
value={sourceTypeFilter}
|
||||||
|
onChange={val => { setSourceTypeFilter(val); setSourceDetailFilter(''); setSelectedTreeNode(null) }}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="按出处"
|
||||||
|
options={[{ label: '全部出处', value: '' }, ...sources.map(s => ({ label: s, value: s }))]}
|
||||||
|
value={sourceDetailFilter}
|
||||||
|
onChange={val => { setSourceDetailFilter(val); setSelectedTreeNode(null) }}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => { fetchItems(); fetchTree() }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
<Card style={{ borderRadius: 14 }}>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={displayedItems}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 10, size: 'small' }}
|
||||||
|
locale={{ emptyText: '暂无知识库条目,上传 md 文件开始入库' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import http from './http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前题图设置
|
||||||
|
* @returns {Promise<{cover_path: string|null, episode_number: number|null, episode_title: string|null}>}
|
||||||
|
*/
|
||||||
|
export async function getCurrentCover() {
|
||||||
|
const response = await http.get('/dashboard/cover')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传题图(仅制片人/责编可用)
|
||||||
|
* @param {File} file - 海报图片文件
|
||||||
|
* @param {number} episodeNumber - 关联期次号
|
||||||
|
* @param {string} episodeTitle - 关联期次节目名
|
||||||
|
* @returns {Promise<{success: boolean, cover_path: string}>}
|
||||||
|
*/
|
||||||
|
export async function uploadCover(file, episodeNumber, episodeTitle) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('episode_number', String(episodeNumber))
|
||||||
|
formData.append('episode_title', episodeTitle)
|
||||||
|
const response = await http.post('/dashboard/cover', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取未来排播计划
|
||||||
|
* @param {number} limit - 返回条数,默认6
|
||||||
|
* @returns {Promise<Array<{episode_number, program_name, planned_air_date, editor_name_snapshot}>>}
|
||||||
|
*/
|
||||||
|
export async function getUpcomingSchedules(limit = 6) {
|
||||||
|
const response = await http.get('/schedules/upcoming', { params: { limit } })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 知识库 API 服务
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from './http'
|
||||||
|
|
||||||
|
const knowledgeService = {
|
||||||
|
/**
|
||||||
|
* 上传 md 文件(单个或多个)
|
||||||
|
* @param {File[]} files - File 对象数组
|
||||||
|
*/
|
||||||
|
async uploadFiles(files) {
|
||||||
|
const formData = new FormData()
|
||||||
|
files.forEach(f => formData.append('files', f))
|
||||||
|
const resp = await http.post('/knowledge/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
return resp.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识库条目列表
|
||||||
|
* @param {string|null} sourceType - 可选,按 source_type 筛选
|
||||||
|
*/
|
||||||
|
async listItems(sourceType = null) {
|
||||||
|
const params = sourceType ? { source_type: sourceType } : {}
|
||||||
|
const resp = await http.get('/knowledge/items', { params })
|
||||||
|
return resp.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除知识库条目
|
||||||
|
* @param {number} id
|
||||||
|
*/
|
||||||
|
async deleteItem(id) {
|
||||||
|
const resp = await http.delete(`/knowledge/items/${id}`)
|
||||||
|
return resp.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有不重复的具体出处(供筛选下拉)
|
||||||
|
*/
|
||||||
|
async listSources() {
|
||||||
|
const resp = await http.get('/knowledge/sources')
|
||||||
|
return resp.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取按来源分组的树形结构(含「全部」根节点)
|
||||||
|
*/
|
||||||
|
async getGroupedItems() {
|
||||||
|
const resp = await http.get('/knowledge/grouped')
|
||||||
|
return resp.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语义检索:输入一段文字,返回最相关的知识库条目
|
||||||
|
* @param {string} queryText - 查询文字
|
||||||
|
* @param {number} topK - 返回条数,默认 5
|
||||||
|
*/
|
||||||
|
async searchItems(queryText, topK = 5) {
|
||||||
|
const resp = await http.post('/knowledge/search', {
|
||||||
|
query: queryText,
|
||||||
|
top_k: topK,
|
||||||
|
})
|
||||||
|
return resp.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default knowledgeService
|
||||||
@@ -10,6 +10,10 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/static': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Phase 3 开发日志
|
||||||
|
|
||||||
|
> 本文件记录 Phase 3(知识库基础设施)各 Task 的已完成事实。
|
||||||
|
> 续接快照(待办/决策)见单独文件,不混入本 log。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 — embedding 最小链路验证 ✅(2026-05-26 完成)
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
用最小代价验证 Phase 3 的命脉:文本能否转成正确维度的向量、整条「解析→embedding→入库→语义检索」链路能否跑通。不碰 schema、不碰前端、不碰 docx。
|
||||||
|
|
||||||
|
### 关键结论(地基事实,后续 Task 依赖)
|
||||||
|
- **embedding 服务确定为 MiniMax embo-01**。前期查证已排除 DeepSeek(官方无 embedding 接口)。
|
||||||
|
- **embo-01 输出维度 = 1536**,与 `001_init.sql` 中 `knowledge_embeddings.embedding vector(1536)` 完全匹配,无需改维度、无需改宪法。
|
||||||
|
- **embo-01 真实接口结构**(实测确认,非凭印象):
|
||||||
|
- 请求:`{"model":"embo-01", "texts":[...], "type":"db"|"query"}` —— 注意是 `texts` 数组,不是 OpenAI 的 `input`。
|
||||||
|
- 返回:向量在 `vectors` 字段(`data["vectors"][0]`),不是 OpenAI 的 `data[0].embedding`。
|
||||||
|
- 存文档用 `type="db"`,查询用 `type="query"`,不可混。
|
||||||
|
- 调用需 `Authorization: Bearer {API_KEY}` + `GroupId` 两个凭证(MiniMax 特有 GroupId)。
|
||||||
|
- **检索方式**:使用 pgvector 数据库原生余弦距离算子 `<=>`,不在 Python 侧计算。
|
||||||
|
- **凭证管理**:API Key + GroupId 存于 `backend/.env`(`MINIMAX_EMBED_API_KEY` / `MINIMAX_GROUP_ID`),代码从 .env 读取,未进任何代码、未进 git(.env 已在 .gitignore)。
|
||||||
|
- 注意:embedding 接口地址 `https://api.minimax.chat/v1/embeddings`,与 Cline 写代码所用的 M2.7 chat 接口是两套独立配置,互不影响。
|
||||||
|
|
||||||
|
### 新增/改动文件(已 commit & push,commit 38873ac)
|
||||||
|
- `backend/requirements.txt`(+pgvector==0.2.5)
|
||||||
|
- `backend/app/core/config.py`(+引导凭证字段)
|
||||||
|
- `backend/app/models/knowledge.py`(新增,embedding 字段用 pgvector.Vector)
|
||||||
|
- `backend/app/services/embedding_service.py`(新增,embo-01 调用封装)
|
||||||
|
- `backend/app/services/knowledge_service.py`(新增,写库 + SQL 向量检索)
|
||||||
|
- `backend/scripts/test_embo01_api.py`(探路脚本)
|
||||||
|
- `backend/scripts/verify_embedding.py`(全链路验证脚本)
|
||||||
|
- `backend/sample_md/`(空目录,测试文件已清)
|
||||||
|
|
||||||
|
### 收尾
|
||||||
|
- 验证用测试数据已全部清除:knowledge_items = 0 行,knowledge_embeddings = 0 行,sample_md/ 空。
|
||||||
|
- 知识库当前为干净空库,等待真实笔记导入。
|
||||||
|
|
||||||
|
### 顺手核实到的现状(未处理,留待办)
|
||||||
|
- episodes 表当前仅 7 行。
|
||||||
|
- Phase 2 的「批量导入」前端有入口但从未真实导入过收视数据(制片人确认:是流程上没提醒导入,加上收视表里有测试数据不会删)。属 Phase 2 遗留债,记入 backlog,另行处理,不并入 Phase 3。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 — 知识库管理后台(最小可用版)✅(2026-05-26 完成)
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
做一条端到端跑通的链路:上传 .md 笔记 → 解析 yaml frontmatter → 入库(含语义向量)→ 列表展示 → 删除。不改 schema、不碰认证、不引向量索引以外的新依赖。
|
||||||
|
|
||||||
|
### 新增/改动文件(4 次独立 commit)
|
||||||
|
|
||||||
|
| 文件 | 动作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `backend/requirements.txt` | 改 | +python-frontmatter==1.1.0 |
|
||||||
|
| `backend/app/services/knowledge_service.py` | 扩展 | +parse_md_file() + delete_item() + list_items() + get_distinct_sources();复用 EmbeddingService |
|
||||||
|
| `backend/app/api/knowledge.py` | 新增 | 4 个接口:POST /upload、GET /items、DELETE /items/{id}、GET /sources |
|
||||||
|
| `backend/app/main.py` | 改 | +app.include_router(knowledge_router) |
|
||||||
|
| `frontend/src/services/knowledgeService.js` | 新增 | 封装 4 个 API 调用 |
|
||||||
|
| `frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx` | 重写 | 上传区(Dragger) + 筛选栏(Select) + 列表(Table) + 删除(Popconfirm) |
|
||||||
|
|
||||||
|
### yaml frontmatter 解析映射(按真实样本,写死)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 类型 → source_type(硬映射,不猜测)
|
||||||
|
杂志文章/军报 → military_report
|
||||||
|
节目文稿 → manuscript
|
||||||
|
报题单 → baoti
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
名称 或 标题 → title
|
||||||
|
|
||||||
|
# 作者
|
||||||
|
作者 或 编导 → author
|
||||||
|
|
||||||
|
# 出处详情(存 JSONB 的 source_detail)
|
||||||
|
期刊 + 期号 → 拼接,如"航空知识 2026年第1期"
|
||||||
|
|
||||||
|
# 播出日期
|
||||||
|
容错"待补充"等非日期文本 → null
|
||||||
|
|
||||||
|
# 权重
|
||||||
|
原样存 JSONB(不展示不排序,Phase 4 排序用)
|
||||||
|
|
||||||
|
# 双链 [[...]]
|
||||||
|
_extract_double_brackets() 原样存入 JSONB 预留给 Phase 4
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
|
||||||
|
1. **source_detail 存 JSONB 不 ALTER**:Task Brief 说"不要新增表字段",`tags` JSONB 列已用于存权重,故 source_detail 也放进来,不 ALTER TABLE。查询时从 `tags->>'source_detail'` 解压。
|
||||||
|
2. **来源筛选动态从 DB 取**:`get_distinct_sources()` 查库里所有 `tags->>'source_detail'` DISTINCT 值,下拉选项不写死,新增一本杂志自动出现。
|
||||||
|
3. **embedding 复用**:直接调用 `EmbeddingService.embed_single(content_md, embed_type="db")`,1536 维向量已验证,不重写。
|
||||||
|
4. **删除 CASCADE**:数据库层已有 `ON DELETE CASCADE`,Python 侧只删 `KnowledgeItem` 即可。
|
||||||
|
|
||||||
|
### 测试结果(已验证)
|
||||||
|
|
||||||
|
| 验收项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| 两篇 md 均成功入库 | ✅ id=27(光辉之路)、id=28(超级战舰) |
|
||||||
|
| 杂志篇显示作者"钱峰",无播出时间 | ✅ author=钱峰, publish_date=null |
|
||||||
|
| 节目篇显示编导"左鑫",播出日期"待补充"不显示 | ✅ author=左鑫, publish_date=null |
|
||||||
|
| source_detail 正确存下("航空知识 2026年第1期") | ✅ tags 中可见 |
|
||||||
|
| 来源筛选下拉可据此筛 | ✅ /api/knowledge/sources 返回 `航空知识 2026年第1期` |
|
||||||
|
| knowledge_embeddings 有对应行(向量已生成) | ✅ 两行,19207/19174 字符(1536 维) |
|
||||||
|
| 删除有二次确认,确认后条目+向量一并消失 | ✅ 删除 id=27 验证,embeddings 级联消失 |
|
||||||
|
|
||||||
|
### commit 历史
|
||||||
|
- `779429a` feat: 添加 python-frontmatter 依赖用于解析 md yaml frontmatter
|
||||||
|
- `7b6ae24` feat: 知识库管理后台上传/列表/删除API,含frontmatter解析
|
||||||
|
- `7155a18` feat: 知识库管理前端页面,含上传/列表/筛选/删除
|
||||||
|
|
||||||
|
### 潜在风险
|
||||||
|
1. **windows CRLF 警告**:每次 commit 都有 CRLF→LF 警告,不影响功能但难看,属 git 全局配置问题(core.autocrlf),下次提醒用户设一下。
|
||||||
|
2. **"待补充"播出日期**:节目文稿的播出日期"待补充"正确存为 null,列表"播出/发表时间"格无值时不显示(render: val || null)。
|
||||||
|
3. **删除后 sources 下拉残留**:删除后 `get_distinct_sources()` 重新查会去掉已删条目的 source_detail,正常。
|
||||||
|
4. **embo-01 调用失败时整条上传失败**:当前是 try-except 包装,失败时返回 errors 数组,不污染已入库数据(因为单条原子),但前端需要展示 errors 反馈(已实现)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 — 知识库树形视图(路线B 按来源分组)✅(2026-05-27 完成)
|
||||||
|
|
||||||
|
> 本段为追加内容,接续 phase3_log 的 Task 1 / Task 2 之后。
|
||||||
|
> Task 3 实际分三轮(Task 3 主体 + 3.1 修复 + 3.2 调整)完成并验收。
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
在 Task 2 的扁平列表外,包一层"可展开/收起的树形导航",按"来源"两层分组,
|
||||||
|
点树节点 → 右侧列表联动过滤。保留原有上传/筛选/删除。不改 schema。
|
||||||
|
|
||||||
|
### 路线选择(已定)
|
||||||
|
- **走路线B:按来源分组**(制片人拍板)。理由:现有字段(source_type + source_detail)即可建树,零返工。
|
||||||
|
- 路线A(按主题分组:涉及装备/技术等)的原料其实已在库(related_entities / tags JSONB),
|
||||||
|
将来转 A 路大概率无需补字段重传,只改分组层 + 映射表。
|
||||||
|
- **解耦红线**:交互层(KnowledgeTree.jsx)与数据分组层(useKnowledgeGrouping.js + 后端 get_grouped_items)分离,
|
||||||
|
转 A 路只改分组层一处,树组件不动。
|
||||||
|
|
||||||
|
### 三轮各自做了什么
|
||||||
|
|
||||||
|
**Task 3(树主体)**
|
||||||
|
- 两层树:来源大类 → 二级(出处)。"全部"为根节点;空大类规则、_none_ 不泄漏。
|
||||||
|
- 交互:展开/收起、全部展开/全部收起、节点高亮、右侧联动。
|
||||||
|
|
||||||
|
**Task 3.1(修复)**
|
||||||
|
- 修出处筛选下拉为空(根因:前端从未调用 listSources,用 items.length 误判;改为调 /sources 动态填充)。
|
||||||
|
- 修页面宽度跳动(右侧容器加 minWidth:0 约束)。
|
||||||
|
- 表格列宽规划;上传区收成一行按钮;隐藏"入库时间"列(仅前端隐藏,DB 字段保留)。
|
||||||
|
|
||||||
|
**Task 3.2(调整)**
|
||||||
|
- 大类固定排序:节目文稿 → 杂志文章 → 报题单 → 其他。
|
||||||
|
- 节目文稿按作者(author)归堆二级(如"左鑫");映射表 SECONDARY_GROUP_FIELD 落地。
|
||||||
|
- 左右两列对齐(曾尝试 flex→Grid 重构,连续改坏,最终回退 flex 止损,保留功能改进)。
|
||||||
|
- 空大类(0条)不显示。
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
1. **二级分组维度按大类不同**:manuscript→author,military_report→source_detail,其余 None。
|
||||||
|
做成**可配置映射表**(前端 useKnowledgeGrouping.js + 后端 knowledge_service.py 同名映射),改一处即可调整。
|
||||||
|
⚠️ 此设计使"第二层维度"在不同大类下含义不同,是制片人知情拍板的临时方案;
|
||||||
|
将来宜把"按编导看稿"迁为独立筛选(见快照 Backlog#3)。
|
||||||
|
2. **点击筛选为前端本地计算**:displayedItems 基于已拉取全量 items + 选中节点实时算,不发网络请求,稳定。
|
||||||
|
3. **过滤按大类用对应字段**:manuscript 比 author,military_report 比 source_detail(Task 3.2 最终修对的关键)。
|
||||||
|
4. **空大类不显示根因**:初始化时 type_groups 预建所有大类空列表,致 continue 失效;改为动态收集真实出现的 source_type。
|
||||||
|
|
||||||
|
### 改动文件
|
||||||
|
| 文件 | 动作 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/app/services/knowledge_service.py` | 新增 get_grouped_items();SOURCE_TYPE_ORDER;SECONDARY_GROUP_FIELD;空大类动态收集 |
|
||||||
|
| `backend/app/api/knowledge.py` | 新增 GET /api/knowledge/grouped |
|
||||||
|
| `frontend/src/services/knowledgeService.js` | 新增 getGroupedItems() |
|
||||||
|
| `frontend/src/hooks/useKnowledgeGrouping.js` | 新增(数据分组层,解耦核心) |
|
||||||
|
| `frontend/src/components/KnowledgeTree/KnowledgeTree.jsx` | 新增(树交互组件) |
|
||||||
|
| `frontend/src/components/KnowledgeTree/KnowledgeTree.css` | 新增 |
|
||||||
|
| `frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx` | 重构左右分栏;按 type 分支过滤;上传区收小;隐藏入库时间列 |
|
||||||
|
|
||||||
|
### 验收结果(制片人真实点页面逐条验,已通过)
|
||||||
|
| 验收项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| 树两层、大类固定顺序(节目文稿在前) | ✅ |
|
||||||
|
| 节目文稿 → 左鑫 → 点击右侧出现超级战舰 | ✅ |
|
||||||
|
| 杂志文章 → 航空知识2026年第1期 → 点击右侧出现对应2篇 | ✅ |
|
||||||
|
| 点"全部"/各节点联动正确 | ✅ |
|
||||||
|
| 全部展开/全部收起、宽度不跳、上传按钮收小 | ✅ |
|
||||||
|
| 空大类(报题单/其他 0条)不显示 | ✅ |
|
||||||
|
| 原有上传/筛选/删除无回归 | ✅ |
|
||||||
|
|
||||||
|
### commit 历史
|
||||||
|
- 树主体与修复多次提交,最终修复 commit `3409d48`(前序含 `769a048` 等)。
|
||||||
|
|
||||||
|
### 教训(重要,下次接手避雷)
|
||||||
|
1. **Cline 自检多次虚标 ✅**:完工报告打勾项实测多坏。纪律:不信自检,制片人真实点页面验;禁止用会误报的检查方式(如跨多行正则)。
|
||||||
|
2. **key 两端对齐反复踩坑**:树节点 key 格式改动时生成端与过滤端未同步,致点节点筛不出。改 key 属高风险,改后必实测联动。
|
||||||
|
3. **布局别轻易换实现方式**:flex→Grid 整体重构连续改坏,靠"回退 flex + 保留功能"止损。已验证好用的布局不为对齐而重构。
|
||||||
|
4. **排查 > 盲改**:Cline 报告自相矛盾或与页面不符时,让制片人直接发源码文件给顾问看,一眼定位胜过反复试错。
|
||||||
|
5. **见好就收**:像素级打磨边际收益递减,记 backlog 改天连同视觉规范统一弄。
|
||||||
|
|
||||||
|
### 潜在风险 / 遗留
|
||||||
|
1. 界面像素级未到位:上传按钮框与筛选栏框上下精确对齐未做到;整体配色/字号属"能接受"非"精致",记 backlog。
|
||||||
|
2. 200+ 笔记尚未批量录入:需先试 10 篇验证解析,且宜等 pdf 存储方案定后再全量(见快照 Backlog#1、#2)。
|
||||||
|
3. Phase 3 内"语义搜索"尚未做(向量已就位,随时可开),是下一刀候选。
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Phase 3 开发日志
|
||||||
|
|
||||||
|
> 本文件记录 Phase 3(知识库基础设施)各 Task 的已完成事实。
|
||||||
|
> 续接快照(待办/决策)见单独文件,不混入本 log。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 — embedding 最小链路验证 ✅(2026-05-26 完成)
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
用最小代价验证 Phase 3 的命脉:文本能否转成正确维度的向量、整条「解析→embedding→入库→语义检索」链路能否跑通。不碰 schema、不碰前端、不碰 docx。
|
||||||
|
|
||||||
|
### 关键结论(地基事实,后续 Task 依赖)
|
||||||
|
- **embedding 服务确定为 MiniMax embo-01**。前期查证已排除 DeepSeek(官方无 embedding 接口)。
|
||||||
|
- **embo-01 输出维度 = 1536**,与 `001_init.sql` 中 `knowledge_embeddings.embedding vector(1536)` 完全匹配,无需改维度、无需改宪法。
|
||||||
|
- **embo-01 真实接口结构**(实测确认,非凭印象):
|
||||||
|
- 请求:`{"model":"embo-01", "texts":[...], "type":"db"|"query"}` —— 注意是 `texts` 数组,不是 OpenAI 的 `input`。
|
||||||
|
- 返回:向量在 `vectors` 字段(`data["vectors"][0]`),不是 OpenAI 的 `data[0].embedding`。
|
||||||
|
- 存文档用 `type="db"`,查询用 `type="query"`,不可混。
|
||||||
|
- 调用需 `Authorization: Bearer {API_KEY}` + `GroupId` 两个凭证(MiniMax 特有 GroupId)。
|
||||||
|
- **检索方式**:使用 pgvector 数据库原生余弦距离算子 `<=>`,不在 Python 侧计算。
|
||||||
|
- **凭证管理**:API Key + GroupId 存于 `backend/.env`(`MINIMAX_EMBED_API_KEY` / `MINIMAX_GROUP_ID`),代码从 .env 读取,未进任何代码、未进 git(.env 已在 .gitignore)。
|
||||||
|
- 注意:embedding 接口地址 `https://api.minimax.chat/v1/embeddings`,与 Cline 写代码所用的 M2.7 chat 接口是两套独立配置,互不影响。
|
||||||
|
|
||||||
|
### 新增/改动文件(已 commit & push,commit 38873ac)
|
||||||
|
- `backend/requirements.txt`(+pgvector==0.2.5)
|
||||||
|
- `backend/app/core/config.py`(+引导凭证字段)
|
||||||
|
- `backend/app/models/knowledge.py`(新增,embedding 字段用 pgvector.Vector)
|
||||||
|
- `backend/app/services/embedding_service.py`(新增,embo-01 调用封装)
|
||||||
|
- `backend/app/services/knowledge_service.py`(新增,写库 + SQL 向量检索)
|
||||||
|
- `backend/scripts/test_embo01_api.py`(探路脚本)
|
||||||
|
- `backend/scripts/verify_embedding.py`(全链路验证脚本)
|
||||||
|
- `backend/sample_md/`(空目录,测试文件已清)
|
||||||
|
|
||||||
|
### 收尾
|
||||||
|
- 验证用测试数据已全部清除:knowledge_items = 0 行,knowledge_embeddings = 0 行,sample_md/ 空。
|
||||||
|
- 知识库当前为干净空库,等待真实笔记导入。
|
||||||
|
|
||||||
|
### 顺手核实到的现状(未处理,留待办)
|
||||||
|
- episodes 表当前仅 7 行。
|
||||||
|
- Phase 2 的「批量导入」前端有入口但从未真实导入过收视数据(制片人确认:是流程上没提醒导入,加上收视表里有测试数据不会删)。属 Phase 2 遗留债,记入 backlog,另行处理,不并入 Phase 3。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 — 知识库管理后台(最小可用版)✅(2026-05-26 完成)
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
做一条端到端跑通的链路:上传 .md 笔记 → 解析 yaml frontmatter → 入库(含语义向量)→ 列表展示 → 删除。不改 schema、不碰认证、不引向量索引以外的新依赖。
|
||||||
|
|
||||||
|
### 新增/改动文件(4 次独立 commit)
|
||||||
|
|
||||||
|
| 文件 | 动作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `backend/requirements.txt` | 改 | +python-frontmatter==1.1.0 |
|
||||||
|
| `backend/app/services/knowledge_service.py` | 扩展 | +parse_md_file() + delete_item() + list_items() + get_distinct_sources();复用 EmbeddingService |
|
||||||
|
| `backend/app/api/knowledge.py` | 新增 | 4 个接口:POST /upload、GET /items、DELETE /items/{id}、GET /sources |
|
||||||
|
| `backend/app/main.py` | 改 | +app.include_router(knowledge_router) |
|
||||||
|
| `frontend/src/services/knowledgeService.js` | 新增 | 封装 4 个 API 调用 |
|
||||||
|
| `frontend/src/pages/KnowledgeBase/KnowledgeBase.jsx` | 重写 | 上传区(Dragger) + 筛选栏(Select) + 列表(Table) + 删除(Popconfirm) |
|
||||||
|
|
||||||
|
### yaml frontmatter 解析映射(按真实样本,写死)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 类型 → source_type(硬映射,不猜测)
|
||||||
|
杂志文章/军报 → military_report
|
||||||
|
节目文稿 → manuscript
|
||||||
|
报题单 → baoti
|
||||||
|
|
||||||
|
# 标题
|
||||||
|
名称 或 标题 → title
|
||||||
|
|
||||||
|
# 作者
|
||||||
|
作者 或 编导 → author
|
||||||
|
|
||||||
|
# 出处详情(存 JSONB 的 source_detail)
|
||||||
|
期刊 + 期号 → 拼接,如"航空知识 2026年第1期"
|
||||||
|
|
||||||
|
# 播出日期
|
||||||
|
容错"待补充"等非日期文本 → null
|
||||||
|
|
||||||
|
# 权重
|
||||||
|
原样存 JSONB(不展示不排序,Phase 4 排序用)
|
||||||
|
|
||||||
|
# 双链 [[...]]
|
||||||
|
_extract_double_brackets() 原样存入 JSONB 预留给 Phase 4
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
|
||||||
|
1. **source_detail 存 JSONB 不 ALTER**:Task Brief 说"不要新增表字段",`tags` JSONB 列已用于存权重,故 source_detail 也放进来,不 ALTER TABLE。查询时从 `tags->>'source_detail'` 解压。
|
||||||
|
2. **来源筛选动态从 DB 取**:`get_distinct_sources()` 查库里所有 `tags->>'source_detail'` DISTINCT 值,下拉选项不写死,新增一本杂志自动出现。
|
||||||
|
3. **embedding 复用**:直接调用 `EmbeddingService.embed_single(content_md, embed_type="db")`,1536 维向量已验证,不重写。
|
||||||
|
4. **删除 CASCADE**:数据库层已有 `ON DELETE CASCADE`,Python 侧只删 `KnowledgeItem` 即可。
|
||||||
|
|
||||||
|
### 测试结果(已验证)
|
||||||
|
|
||||||
|
| 验收项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| 两篇 md 均成功入库 | ✅ id=27(光辉之路)、id=28(超级战舰) |
|
||||||
|
| 杂志篇显示作者"钱峰",无播出时间 | ✅ author=钱峰, publish_date=null |
|
||||||
|
| 节目篇显示编导"左鑫",播出日期"待补充"不显示 | ✅ author=左鑫, publish_date=null |
|
||||||
|
| source_detail 正确存下("航空知识 2026年第1期") | ✅ tags 中可见 |
|
||||||
|
| 来源筛选下拉可据此筛 | ✅ /api/knowledge/sources 返回 `航空知识 2026年第1期` |
|
||||||
|
| knowledge_embeddings 有对应行(向量已生成) | ✅ 两行,19207/19174 字符(1536 维) |
|
||||||
|
| 删除有二次确认,确认后条目+向量一并消失 | ✅ 删除 id=27 验证,embeddings 级联消失 |
|
||||||
|
|
||||||
|
### commit 历史
|
||||||
|
- `779429a` feat: 添加 python-frontmatter 依赖用于解析 md yaml frontmatter
|
||||||
|
- `7b6ae24` feat: 知识库管理后台上传/列表/删除API,含frontmatter解析
|
||||||
|
- `7155a18` feat: 知识库管理前端页面,含上传/列表/筛选/删除
|
||||||
|
|
||||||
|
### 潜在风险
|
||||||
|
1. **windows CRLF 警告**:每次 commit 都有 CRLF→LF 警告,不影响功能但难看,属 git 全局配置问题(core.autocrlf),下次提醒用户设一下。
|
||||||
|
2. **"待补充"播出日期**:节目文稿的播出日期"待补充"正确存为 null,列表"播出/发表时间"格无值时不显示(render: val || null)。
|
||||||
|
3. **删除后 sources 下拉残留**:删除后 `get_distinct_sources()` 重新查会去掉已删条目的 source_detail,正常。
|
||||||
|
4. **embo-01 调用失败时整条上传失败**:当前是 try-except 包装,失败时返回 errors 数组,不污染已入库数据(因为单条原子),但前端需要展示 errors 反馈(已实现)。
|
||||||
+105
-30
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 项目:TPS(Topic Planning System)中台 — 央视《军事科技》栏目内部工作台
|
> 项目:TPS(Topic Planning System)中台 — 央视《军事科技》栏目内部工作台
|
||||||
> 仓库:`tps-dashboard`
|
> 仓库:`tps-dashboard`
|
||||||
> 修订版本:v4 · 2026-05-14
|
> 修订版本:v5 · 2026-05-27
|
||||||
> 制片人:刘通
|
> 制片人:刘通
|
||||||
> 协作大模型:Claude Opus 4.7(顾问)+ MiniMax M2.7(Plan + Act,Cline 内双角色物理隔离)
|
> 协作大模型:Claude Opus 4.7(顾问)+ MiniMax M2.7(Plan + Act,Cline 内双角色物理隔离)
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
技术实施细节、阶段时间线在姊妹文档 `dev_plan.md`;协作规则在 `.clinerules`。
|
技术实施细节、阶段时间线在姊妹文档 `dev_plan.md`;协作规则在 `.clinerules`。
|
||||||
|
|
||||||
**这三份文件长期不变**;每周执行细节进 `logs/phase{N}_log.md`,不进本文。
|
**这三份文件是宪法,改动应慎重,但不是不可变**——随着开发推进、制片人对系统的理解越来越具体,宪法会迭代升级(如 v5 把 TPS 从"结果列表"升级为"对话式策划助手")。每周执行细节进 `logs/phase{N}_log.md`,不进本文。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -44,6 +44,13 @@
|
|||||||
|
|
||||||
这一条贯穿所有模块设计、所有 AI Prompt、所有 UI 决策。任何让 AI 替编导拍板的功能都不要做;任何把编导逼向"按 AI 建议改"的设计都要重做。
|
这一条贯穿所有模块设计、所有 AI Prompt、所有 UI 决策。任何让 AI 替编导拍板的功能都不要做;任何把编导逼向"按 AI 建议改"的设计都要重做。
|
||||||
|
|
||||||
|
**TPS 的产品定位(v5 明确)**:TPS 选题策划系统是一个**"带着栏目全部家底的策划助手"**,不是"主编的数字分身"。这半格之差是产品的命:
|
||||||
|
|
||||||
|
- **主编**会说"我建议你这么做"——带判断倾向,替编导拿主意。
|
||||||
|
- **助手**会说"顺着你这个思路,栏目里有这些相关家底,要不要我帮你理一理"——把判断的原料和脉络铺好,人来拍板。
|
||||||
|
|
||||||
|
TPS 做后者。它可以对话、可以加工、可以给有依据的分析("这个角度历史上收视偏低,可能和切入点有关"),但分析永远是**摆事实 + 给关联**,不是下结论("所以别做")。**AI 摆牌给编导看,牌由编导自己出。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、项目目标
|
## 三、项目目标
|
||||||
@@ -91,17 +98,34 @@
|
|||||||
|
|
||||||
## 五、产品功能模块(8 个)
|
## 五、产品功能模块(8 个)
|
||||||
|
|
||||||
### 模块 A — 选题查重(TPS 核心入口)
|
### 模块 A — 选题查重与对话式策划(TPS 核心入口)
|
||||||
|
|
||||||
**痛点**:编导提一个选题,担心做过、想知道历史成绩。
|
**痛点**:编导有一个创意或灵感,担心做过、想知道历史成绩;新编导面对一堆历史节目,不知道怎么用、看不出"so what"。
|
||||||
|
|
||||||
**功能**:
|
**形态(v5 升级——从"结果列表"升级为"对话式策划助手")**:
|
||||||
- 编导输入选题简介 → 系统语义检索往期 `topics` + `episodes`
|
|
||||||
- 列出 5 条相似度最高的历史节目,**展示原文 + 当年收视成绩 + 颜色标识(红蓝绿)**
|
|
||||||
- 点开任一相似节目 → 联动模块 E,展示**外拍联系人 / 联系方式 / 线索来源**
|
|
||||||
- 编导自行判断:"这选题做过但收视很高可以重做"或"这个角度我们没碰过"
|
|
||||||
|
|
||||||
**AI 哲学**:**不告诉编导"建议放弃 / 建议做"**,只展示事实让其自决。
|
界面采用**左右双栏**:
|
||||||
|
|
||||||
|
- **左栏**:编导与 TPS 的对话区。编导输入只言片语的灵感,可持续追问、逐步把思路聊清楚。
|
||||||
|
- **右栏**:一份由 AI 加工的**策划参考报告**,随对话深入而越来越贴合。
|
||||||
|
|
||||||
|
报告内容(全部基于栏目自己的知识库,有依据、可追溯):
|
||||||
|
|
||||||
|
- 这个选题方向**之前有没有人做过、是谁做的、收视成绩怎么样**(联动 `episodes`,颜色判定见第 6.1 节)
|
||||||
|
- 当时**领导有什么点评、审片意见**(联动模块 D / `review_comments`)
|
||||||
|
- **有没有可参考的往期节目**(提供节目链接/索引)
|
||||||
|
- 若涉及外拍,**是哪个单位、联系人是谁**(联动模块 E / `shooting_resources`)
|
||||||
|
- 相关的**往期文稿 / 专业期刊 / 权威资料**(联动模块 C / `knowledge_items`)
|
||||||
|
|
||||||
|
**三条不可动摇的交互红线(经业界研究验证,见 v5 起草说明)**:
|
||||||
|
|
||||||
|
1. **求助式,不是投喂式**:报告默认是编导"叫出来"的,而非编导刚敲半句就自动甩一整屏盖住。展开与深入由编导主动触发——主动权握在"要不要展开"这个动作上。
|
||||||
|
2. **脚注式锚定引用**:报告里每个结论后挂一个不打扰阅读的小角标,点开才看到依据(哪期节目/哪篇期刊)。**不大面积平铺引用**,仿纸质图书脚注。此设计既让编导能一键核实、不盲信,也经研究证明能降低对 AI 的过度依赖。
|
||||||
|
3. **持续对话沉淀编导的判断**:编导每聊一轮都在投入自己的思路,聊到最后报告里有编导自己的烙印,而非 AI 一次性吐出的成品。聊定后,可一键转入模块 G 生成报题单。
|
||||||
|
|
||||||
|
**AI 哲学**:**不告诉编导"建议放弃 / 建议做"**,只摆事实、给关联、做有依据的分析,让编导自决。
|
||||||
|
|
||||||
|
**落地顺序(重要)**:此对话式形态属 Phase 4a,且**必须在知识库笔记 + 收视数据真实灌入之后**才上线。库空时此助手"无米下锅",依据全空会毁掉第一印象。Phase 3 内先做**朴素语义检索**(输入灵感→返回最相关的几条带相关度、原文片段、来源),作为此形态的第一块积木。
|
||||||
|
|
||||||
### 模块 B — 编导画像建议
|
### 模块 B — 编导画像建议
|
||||||
|
|
||||||
@@ -115,16 +139,19 @@
|
|||||||
**1.0 形态**:粗框架,文字一段。
|
**1.0 形态**:粗框架,文字一段。
|
||||||
**2.0 形态**:动态画像,根据每期作品自动迭代。
|
**2.0 形态**:动态画像,根据每期作品自动迭代。
|
||||||
|
|
||||||
### 模块 C — 知识库参考
|
### 模块 C — 知识库参考(策划报告的依据来源)
|
||||||
|
|
||||||
**痛点**:写选题时缺背景资料,需要去翻往期文稿、军报、专家观点。
|
**痛点**:写选题时缺背景资料,需要去翻往期文稿、军报、专家观点;更进一步,编导希望 AI 给的策划建议是"有出处的",不是凭空说。
|
||||||
|
|
||||||
**功能**:
|
**形态(v5 升级)**:模块 C 是模块 A 右栏报告的**知识库依据供给侧**。
|
||||||
- 提交选题时,系统语义检索 `knowledge_items`(往期文稿 + 军报 + 内部资料)
|
|
||||||
- 列出 3-5 条最相关条目,展示摘要 + 关键词,可点开查看原文
|
|
||||||
- 编导可选择性引用,**引用关系入库**(为未来"知识溯源"打基础)
|
|
||||||
|
|
||||||
**关键技术**:pgvector + DeepSeek embedding(或 OpenAI 兼容 embedding API)+ 1536 维向量。
|
- 编导在对话中表达灵感时,系统语义检索 `knowledge_items`(往期文稿 + 军报 + 专业期刊 + 内部资料)
|
||||||
|
- 检索到的条目不再是孤立"列 3-5 条让编导自己翻",而是**融入右栏报告**,作为报告里相关论断的**脚注式引用依据**(见模块 A 红线 2)
|
||||||
|
- 编导可点开任一脚注查看原文摘要 + 关键词,可选择性引用,**引用关系入库**(为 2.0"知识溯源"打基础)
|
||||||
|
|
||||||
|
**关键技术**:pgvector + MiniMax embo-01 embedding + 1536 维向量(embedding 服务已于 Phase 3 Task 1 确定为 MiniMax embo-01,见 phase3_log)。
|
||||||
|
|
||||||
|
**朴素检索先行**:Phase 3 内先把"输入文本→返回最相关知识库条目"这条朴素检索链路接通(带相关度、原文片段、来源),它是模块 A 对话式报告的检索底座。
|
||||||
|
|
||||||
### 模块 D — 历史审片意见展示
|
### 模块 D — 历史审片意见展示
|
||||||
|
|
||||||
@@ -156,13 +183,22 @@
|
|||||||
|
|
||||||
### 模块 G — 报题单生成与下载
|
### 模块 G — 报题单生成与下载
|
||||||
|
|
||||||
**痛点**:报题单格式各编导各异,审核耗时。
|
**痛点**:报题单格式各编导各异,审核耗时;水平参差的编导写的报题单结构残缺、要素不全、漏洞百出。
|
||||||
|
|
||||||
|
**形态(v5 明确)**:编导与 TPS 在模块 A 里聊定选题后,点一个按钮,把**编导已定的内容**按《军事科技》报题单格式一键生成 `.docx`,提供下载。
|
||||||
|
|
||||||
**功能**:
|
|
||||||
- 编导填好选题信息 + 选好相似参考 + 选好知识库引用 → 一键生成 `.docx` 报题单
|
|
||||||
- 模板由 docxtpl 渲染,**严格遵守"双段结构"**(引子 + 列举,见 `.clinerules` 第 5.4 节)
|
- 模板由 docxtpl 渲染,**严格遵守"双段结构"**(引子 + 列举,见 `.clinerules` 第 5.4 节)
|
||||||
|
- AI 通过学习成熟报题单范本,帮助生成的报题单**更规范**:补齐结构、统一格式、提示遗漏要素、校对装备名/概念名——专治"漏洞百出"
|
||||||
- 编导可下载、修改、打印
|
- 编导可下载、修改、打印
|
||||||
|
|
||||||
|
**"一键生成"的分寸线(死规矩,不可越)**:
|
||||||
|
|
||||||
|
> 这个按钮做的是**"按范本规范化呈现编导已定的内容"**——排版 + 校对 + 结构补齐 + 遗漏提示。
|
||||||
|
> AI **不替编导生产选题立意、不代写内容**。规范化只作用于"格式、结构、要素齐全、字词准确"这一层;选题的内容和立意永远是编导的创作。
|
||||||
|
> 一旦 AI 在内容立意上动手,就从"助手排版"滑回了"代写",违背创作主权红线。
|
||||||
|
|
||||||
|
**落地顺序**:属 Phase 4a。
|
||||||
|
|
||||||
### 模块 H — 编导个人首页 + 热点雷达
|
### 模块 H — 编导个人首页 + 热点雷达
|
||||||
|
|
||||||
**痛点**:编导想知道"今天有什么军事新闻可能值得做",目前靠各自刷微博/微信。
|
**痛点**:编导想知道"今天有什么军事新闻可能值得做",目前靠各自刷微博/微信。
|
||||||
@@ -204,6 +240,18 @@
|
|||||||
默认:**引子(200-300 字)+ 列举(3-5 条 Markdown 序号,每条以 `**加粗关键词**` 开头)**。
|
默认:**引子(200-300 字)+ 列举(3-5 条 Markdown 序号,每条以 `**加粗关键词**` 开头)**。
|
||||||
备选:纯叙述,**仅当编导明确指定才用**。
|
备选:纯叙述,**仅当编导明确指定才用**。
|
||||||
|
|
||||||
|
### 6.5 AI 摆牌不出牌(创作主权红线,v5 新增)
|
||||||
|
|
||||||
|
TPS 是"策划助手"不是"主编数字分身"(见第 2.3 节)。落到每一处 AI 交互:
|
||||||
|
|
||||||
|
- **摆事实、给关联、做有依据的分析**——可以。
|
||||||
|
- **下结论、给倾向、替编导拿主意**("建议放弃/建议这么改")——不可以。
|
||||||
|
- AI 的所有输出都是**"摆给编导看的牌"**,牌由编导自己出。
|
||||||
|
- 凡 AI 输出处,UI 必须给编导"忽略 / 我来改写"的否决权。
|
||||||
|
- 报题单"一键生成"只做规范化呈现,**不代写选题立意**(见模块 G 分寸线)。
|
||||||
|
|
||||||
|
> 自检口诀:这句 AI 的话,是"把原料铺给你看",还是"替你做了决定"?后者一律改掉。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、技术栈与红线(精简版)
|
## 七、技术栈与红线(精简版)
|
||||||
@@ -261,11 +309,11 @@
|
|||||||
|
|
||||||
| Phase | 时间 | 核心交付 | 完成度 |
|
| Phase | 时间 | 核心交付 | 完成度 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Phase 0 | Day 1-2 | 环境搭建(Python/Node/PG/Git) | 家用机已完成,单位机重启 |
|
| Phase 0 | Day 1-2 | 环境搭建(Python/Node/PG/Git) | ✅ 已完成 |
|
||||||
| Phase 1 | Week 1 | 后端 + 前端骨架 + 登录跑通 | 家用机后端已完成,前端待 |
|
| Phase 1 | Week 1 | 后端 + 前端骨架 + 登录跑通 | ✅ 已完成 |
|
||||||
| Phase 2 | Week 2-3 | 仪表盘 + 用户管理 + Excel 导入 | 未开始 |
|
| Phase 2 | Week 2-3 | 仪表盘 + 用户管理 + Excel 导入 | ✅ 已完成 |
|
||||||
| Phase 3 | Week 4 | 知识库基础设施 + 报题单导入 | 未开始 |
|
| Phase 3 | Week 4 | 知识库基础设施 + 报题单导入 | 进行中(已完成至 Task 3 知识库树形视图;语义检索尚未做) |
|
||||||
| Phase 4a | Week 5-6 | **MVP 上线**(报题单生成 + 查重 + 知识库参考) | 未开始 |
|
| Phase 4a | Week 5-6 | **MVP 上线**(对话式 TPS + 报题单生成 + 知识库参考) | 未开始 |
|
||||||
| Phase 4b | Week 7-9 | 画像 + 甘特图 + 资源 | 未开始 |
|
| Phase 4b | Week 7-9 | 画像 + 甘特图 + 资源 | 未开始 |
|
||||||
| Phase 4c | Week 10-11 | 热点雷达 | 未开始 |
|
| Phase 4c | Week 10-11 | 热点雷达 | 未开始 |
|
||||||
| Phase 5 | Week 11-12 | 部署上线 + 反向代理整合 + Buffer | 未开始 |
|
| Phase 5 | Week 11-12 | 部署上线 + 反向代理整合 + Buffer | 未开始 |
|
||||||
@@ -291,13 +339,39 @@
|
|||||||
|
|
||||||
**接入信息**:`base_url = https://api.minimaxi.com/v1`,Model ID = `MiniMax-M2.7`(标准版,非 highspeed),API Key 已由制片人申请就绪。
|
**接入信息**:`base_url = https://api.minimaxi.com/v1`,Model ID = `MiniMax-M2.7`(标准版,非 highspeed),API Key 已由制片人申请就绪。
|
||||||
|
|
||||||
|
> 注意:上表是**开发期**的模型分工(谁帮我们写代码)。下文第十一章讲的是**产品运行期**的 AI 调用成本(系统上线后,编导每天用 TPS 烧的钱),两者是两笔账。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十一、风险与应对
|
## 十一、AI 成本与额度原则(v5 新增)
|
||||||
|
|
||||||
|
TPS 升级为对话式策划助手后,编导每用一次都要调大模型。8 人长期高频使用,这笔运行成本不小,且对话式产品有个特点:**用户聊得越爽烧得越多,而自己往往不知道在烧钱**。本节定原则,具体收费方式待 MVP 能用后再拍。
|
||||||
|
|
||||||
|
### 11.1 成本归属(待确认,但方向先定)
|
||||||
|
|
||||||
|
- **优先争取栏目报销**:TPS 是栏目的生产力工具,理应走栏目经费。系统未上线前无法确认能否报销,**先记为待办,不要现在自己垫钱去设计收费系统**。
|
||||||
|
- **不向编导收费**(明确反对):让编导自己充值或填个人 API Key,等于给"用不用这个工具"装了计价器,跟"对用户像对客户、鼓励新编导多用多练"的初心直接打架。**新编导越该多用,越不能让他舍不得花钱而不敢用。**
|
||||||
|
|
||||||
|
### 11.2 1.0 必须预埋的三件事(不贵,但必须从第一天就埋,事后补极难)
|
||||||
|
|
||||||
|
> 以下为业界对 LLM 应用控成本的共识做法,已用大白话翻译;**取其神,弃其形**——下面"不要做"那条尤其重要。
|
||||||
|
|
||||||
|
1. **按"用量(token)"记,不是按"次数"记**:同样问一次,随口问一句和把整篇文稿贴进去追问,成本差几十倍。所以计量与限额都要按真实消耗的量,而不是"今天能问几次"。
|
||||||
|
2. **按"人 + 选题"归因**:每次调用都打上"是谁、为哪个选题"的标签。这是"费用不能都制片人出"的技术前提——将来无论报销、分摊还是台里统一付,账都清楚。
|
||||||
|
3. **保守上限 + 超额提醒管理员**:上线时给每人先卡一个偏紧的月度额度,超了提醒管理员(制片人),看真实用量再松绑。绝不允许"某编导一个通宵烧光全月预算"。
|
||||||
|
|
||||||
|
### 11.3 不要做的(防止 Cline 上重型武器)
|
||||||
|
|
||||||
|
业界控成本的文章常提"AI 网关 / OpenTelemetry / 可观测性平台"——**那是给几百上千人、多团队大公司用的重武器,8 人小项目完全不需要**。我们要的就是最朴素的:每次调用往一张表记一行(谁、何时、烧多少、为哪个选题),管理员页面看汇总、设上限。**Cline 若提议引入网关或可观测性框架,一律驳回。**
|
||||||
|
|
||||||
|
> 落地 Phase:额度机制随对话式 TPS(Phase 4a)一起做;Phase 3 朴素检索只调 embedding(每篇几厘钱量级),暂不涉及此机制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、风险与应对
|
||||||
|
|
||||||
| 风险 | 概率 | 应对 |
|
| 风险 | 概率 | 应对 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Phase 1 前端骨架延期 | 中 | 单位机有家用机的代码 zip 可参考,降低重写成本 |
|
|
||||||
| MiniMax M2.7 切入 Plan 后方案质量下降 | 中 | 临时切回 claude.ai 找 Opus 4.7 重新规划,不在 Cline 里换模型 |
|
| MiniMax M2.7 切入 Plan 后方案质量下降 | 中 | 临时切回 claude.ai 找 Opus 4.7 重新规划,不在 Cline 里换模型 |
|
||||||
| 历史 Excel / docx 导入解析失败 | 高 | 解析失败的文件单独列出,提示责编手工修正后重传 |
|
| 历史 Excel / docx 导入解析失败 | 高 | 解析失败的文件单独列出,提示责编手工修正后重传 |
|
||||||
| 编导抗拒使用新工具 | 中 | "辅助不替代"的产品哲学贯穿,绝不强制;先让最积极的 1-2 位编导先用,产生口碑 |
|
| 编导抗拒使用新工具 | 中 | "辅助不替代"的产品哲学贯穿,绝不强制;先让最积极的 1-2 位编导先用,产生口碑 |
|
||||||
@@ -306,7 +380,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十二、不在 1.0 范围内的事
|
## 十三、不在 1.0 范围内的事
|
||||||
|
|
||||||
明确不做、留到 2.0 或更远的:
|
明确不做、留到 2.0 或更远的:
|
||||||
|
|
||||||
@@ -322,14 +396,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十三、版本演进
|
## 十四、版本演进
|
||||||
|
|
||||||
| 版本 | 日期 | 主要变化 |
|
| 版本 | 日期 | 主要变化 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v1 | 2026-04 | 初版,定模块 A-G(7 个) |
|
| v1 | 2026-04 | 初版,定模块 A-G(7 个) |
|
||||||
| v2 | 2026-05-04 | 加模块 H 热点雷达,增 2.0 兼容预埋说明 |
|
| v2 | 2026-05-04 | 加模块 H 热点雷达,增 2.0 兼容预埋说明 |
|
||||||
| v3 | 2026-05-06 | 加 MiniMax M2.7 接班 Plan 模式分工(第十章)、Git 仓库信息、单位机重启路径、风险表中 Gitea 项;DeepSeek pro 全程在岗 Act |
|
| v3 | 2026-05-06 | 加 MiniMax M2.7 接班 Plan 模式分工(第十章)、Git 仓库信息、单位机重启路径、风险表中 Gitea 项;DeepSeek pro 全程在岗 Act |
|
||||||
| **v4** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** |
|
| v4 | 2026-05-14 | 家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录 |
|
||||||
|
| **v5** | **2026-05-27** | **TPS 定位升级:模块 A/C/G 从"结果列表"升级为"对话式策划助手"(左对话右报告、脚注式锚定引用、求助式不投喂);新增第 2.3 节产品定位、6.5 节"AI 摆牌不出牌"红线、第十一章"AI 成本与额度原则";明确朴素语义检索(Phase 3)→ 对话式 TPS(Phase 4a,数据灌入后)的落地顺序;清理过期风险项** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user