27 Commits

Author SHA1 Message Date
simonkoson 9a86c936ee chore: gitignore 忽略前端测试题图截图 2026-05-27 19:53:26 +08:00
simonkoson 9acb9d380d docs: 新增 claude 协作原则文件,backlog 待办池入库 2026-05-27 19:51:41 +08:00
simonkoson 47c9d60f02 docs: phase3_log 收尾,Task 3/4 记录归位,删除合并后遗留散文件 2026-05-27 19:51:31 +08:00
simonkoson cd4dcd06f8 docs: 宪法升级至 v5,TPS 定位为对话式策划助手 + README 进度校准 2026-05-27 19:51:23 +08:00
simonkoson 783e212bb1 feat: 知识库朴素语义搜索(输入→检索→结果列表) 2026-05-27 19:25:41 +08:00
simonkoson 0c7d2d7400 fix: type_groups动态收集,空大类从根上不产生节点 2026-05-27 15:00:52 +08:00
simonkoson 70784fc70b fix: 过滤按大类用对应字段比对,修复左鑫节点无法筛选问题 2026-05-27 14:53:11 +08:00
simonkoson 347a06e48b fix: 回退Grid布局恢复flex并列,保留二级分组映射,空大类隐藏 2026-05-27 14:35:32 +08:00
simonkoson fff2cfe395 fix: 知识库key格式统一、过滤逻辑修复、布局宽度稳定 2026-05-27 14:06:16 +08:00
simonkoson a00bcd6c73 feat: 知识库树形视图调整:大类固定排序、节目文稿按作者二级分组、左右Grid双列对齐 2026-05-27 13:54:14 +08:00
simonkoson b3157216bb style: 知识库页面三处样式收尾:内容区扩展、隐藏入库时间列、上传区收小 2026-05-27 13:21:04 +08:00
simonkoson d849bc1539 fix: 修复知识库页面三处问题:出处下拉为空、宽度跳动、表格列宽优化 2026-05-27 13:06:06 +08:00
simonkoson 74a0508755 feat: 知识库树形视图(按来源分组),左侧树导航+右侧列表联动 2026-05-27 09:35:36 +08:00
simonkoson 7999fd14c8 docs: 更新 phase3_log Task 2 完工记录 2026-05-26 18:59:39 +08:00
simonkoson 5ef1b8ec8d feat: 知识库管理前端页面,含上传/列表/筛选/删除 2026-05-26 18:52:34 +08:00
simonkoson 855f103ce8 feat: 知识库管理后台上传/列表/删除API,含frontmatter解析 2026-05-26 18:51:12 +08:00
simonkoson d81969b4b5 feat: 添加 python-frontmatter 依赖用于解析 md yaml frontmatter 2026-05-26 18:48:48 +08:00
simonkoson 04a35faba3 chore(phase3): 补提交 Task1 日志/清理脚本 + 栏目logo,忽略密码重置脚本 2026-05-26 15:24:31 +08:00
simonkoson 1325807257 feat(phase3): Task1 embedding链路验证 - embo-01(1536维)+pgvector检索打通 2026-05-26 10:33:25 +08:00
simonkoson d40d46a434 fix: TargetOutlined换为LineChartOutlined避免白屏 2026-05-25 14:23:44 +08:00
simonkoson 2881b866a6 feat: KPI细条新增两项完成率 + 侧边菜单加两个占位项 2026-05-25 13:45:00 +08:00
simonkoson 72ae73764a feat: 仪表盘视觉优化-居中/logo放大/柱图数字挪顶/节目名加长 2026-05-25 13:38:33 +08:00
simonkoson 51a34e9ec6 feat: 添加栏目 logo 素材 2026-05-25 09:56:15 +08:00
simonkoson 303995a93f fix: dashboard.py 顶层 import CoverSettings,解决运行时 500 2026-05-25 09:55:05 +08:00
simonkoson d774370a4b feat: 仪表盘布局重排,新增题图上传能力 2026-05-25 09:47:21 +08:00
simonkoson a6e8cc25ec feat: 新增题图上传接口和排播接口 2026-05-25 09:44:34 +08:00
simonkoson 8299b6bbc9 feat: 新增 cover_settings 表用于仪表盘题图设置 2026-05-25 09:42:25 +08:00
38 changed files with 3266 additions and 323 deletions
+8
View File
@@ -41,3 +41,11 @@ homePC/
# 臉媒啪脻偶芒膮赂藝脻 / SQL dump(藳钮藵艡掳膰膮啪偶芒)
backups/
# 鏁忔劅鑴氭湰(姘镐笉涓婁紶)
backend/scripts/reset_password.py
# 前端测试/占位图片(题图上传测试产物)
frontend/src/assets/*.jpg
frontend/src/assets/*.jpeg
frontend/src/assets/*.png
+6 -4
View File
@@ -47,10 +47,11 @@
| Phase | 状态 |
|---|---|
| Phase 0 环境 + Git | 单位机进行中 |
| Phase 1 后端骨架 | 家用机已完成,单位机走"路径 B 迁移" |
| Phase 1 前端骨架 | 未开始 |
| Phase 2-5 | 未开始 |
| Phase 0 环境 + Git | ✅ 已完成 |
| Phase 1 后端骨架 + 前端骨架 | ✅ 已完成 |
| Phase 2 仪表盘 + 批量导入 + 写权限收紧 | ✅ 已完成 |
| Phase 3 知识库基础设施 | 进行中 — 已完成至 Task 3(知识库树形视图按来源分组),语义搜索尚未做 |
| Phase 45 | 未开始 |
---
@@ -187,6 +188,7 @@ uvicorn app.main:app --reload
|---|---|---|
| v1 | 2026-05-06 | 初版,作为单位机仓库门牌 |
| **v2** | **2026-05-14** | **家用机退役,唯一开发环境为单位 4090D;Phase 2 起 MiniMax M2.7 正式日常使用;更新版本演进记录** |
| **v3** | **2026-05-27** | **进度校准:Phase 1/2 标为完成;Phase 3 Task 3 知识库树形视图已完成;清理"家用机/路径B"历史表述,不再保留迁移叙事** |
---
+121
View File
@@ -0,0 +1,121 @@
"""
仪表盘 API — 题图上传 / 查询
权限:
- POST /cover — 仅 zhipianren / zebianbiandao 返回 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}
+132
View File
@@ -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),
}
+70
View File
@@ -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
+6
View File
@@ -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")
_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:
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
@@ -25,6 +29,8 @@ class Settings:
DATABASE_URL: str = _DATABASE_URL
SECRET_KEY: str = _SECRET_KEY
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()
+14
View File
@@ -6,11 +6,17 @@ from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
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.episodes import router as episodes_router
from app.api.imports import router as imports_router
from app.api.users import router as users_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
app = FastAPI(title="军事科技工作台", version="0.1.0")
@@ -42,3 +48,11 @@ app.include_router(episodes_router)
app.include_router(imports_router)
app.include_router(yearly_targets_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")
+28
View File
@@ -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()),
)
+55
View File
@@ -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()),
)
+33
View File
@@ -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()),
)
+69
View File
@@ -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]
+354
View File
@@ -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)
# 调用 embeddingtype="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,
}]
+1
View File
@@ -9,5 +9,6 @@ itsdangerous==2.2.0
python-multipart==0.0.9
python-dotenv==1.0.1
httpx==0.27.0
python-frontmatter==1.1.0
pandas>=2.0.0
openpyxl>=3.1.0
+28
View File
@@ -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()
+70
View File
@@ -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}")
+79
View File
@@ -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()
+23
View File
@@ -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;
-- ============================================================
-- 迁移完成
-- ============================================================
+13
View File
@@ -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
View File
@@ -2,7 +2,7 @@
> 项目:TPS(Topic Planning System)中台
> 仓库:`tps-dashboard`(Gitea)
> 修订版本:v4 · 2026-05-14
> 修订版本:v5 · 2026-05-27
> 配套文档:`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 模板)
- 模板严格遵守"双段结构"(`.clinerules` 5.4)
> v5 升级:模块 A/C/G 合成"对话式策划助手"形态(左对话右报告、脚注式引用),详见 `project_plan.md` 模块 A/C/G + 第 2.3 节 + 6.5 节红线。
**模块 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**。
@@ -486,7 +490,8 @@ tps-dashboard/ ← Git 仓库根
| v1 | 2026-04 | 初版 |
| 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 收尾仪式 |
| **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(全局依赖搬进去)。
- 隐患 AImportError 与 Python 内置同名(低优)。
- 隐患 BExcelService 返回 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>
)
}
+3 -1
View File
@@ -29,4 +29,6 @@
padding: 24px;
background: var(--color-bg-cream);
min-height: calc(100vh - 64px);
}
max-width: 1400px;
margin: 0 auto;
}
+25 -1
View File
@@ -35,4 +35,28 @@
.side-nav-menu .ant-menu-item-selected {
background: var(--color-accent-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,
UserOutlined,
TeamOutlined,
SoundOutlined,
} from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
@@ -23,6 +24,28 @@ function SideNav() {
{ key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' },
{ key: '/editor-home', icon: <UserOutlined />, 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,
},
]
// 按角色过滤菜单项
+171
View File
@@ -0,0 +1,171 @@
/**
* 知识库数据分组层(交互层与分组维度完全解耦)
*
* 本模块职责:
* - 接收 items 数组,按指定维度生成分组树数据
* - 完全不涉及 UI(无 React 组件、无 Ant Design 引用)
*
* 将来切换为「按主题/装备分组」时:
* - 只改本文件中的 groupItemsBySource 函数
* - 前端 KnowledgeTree 组件一行都不用动
*/
/**
* ============================================================
* 📌 二级分组维度映射表(核心配置,一处可改)
* ============================================================
* key = source_typevalue = 用来做二级分组的字段名(取 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
+262 -117
View File
@@ -1,149 +1,173 @@
.dashboard {
max-width: 1200px;
padding: 12px;
}
/* Banner */
/* ===== Banner ===== */
.dashboard-banner {
background: #fff;
border-radius: var(--radius-card);
padding: 24px;
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;
width: 100%;
height: 150px;
border-radius: 14px;
overflow: hidden;
position: relative;
background: #3a4a3a;
margin-bottom: 12px;
}
.banner-placeholder-gradient {
.banner-bg {
position: absolute;
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 {
position: absolute;
bottom: 6px;
right: 6px;
z-index: 2;
bottom: 16px;
right: 16px;
z-index: 3;
}
.placeholder-label {
/* 默认渐变底图(无题图时) */
.banner-default-bg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
color: rgba(255,255,255,0.85);
font-size: 13px;
font-weight: 600;
letter-spacing: 2px;
inset: 0;
background: linear-gradient(135deg, #3a4a3a 0%, #5a7a5a 50%, #7a9a6a 100%);
}
/* KPI Cards */
.kpi-row {
margin-bottom: 24px;
/* ===== KPI Strip ===== */
.kpi-strip {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.kpi-card {
border-radius: var(--radius-card);
.kpi-chip {
flex: 1;
display: flex;
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 {
width: 56px;
height: 56px;
border-radius: 12px;
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.kpi-info {
display: flex;
flex-direction: column;
}
.kpi-value {
font-size: 24px;
font-weight: 600;
color: var(--color-text-primary);
flex-shrink: 0;
}
.kpi-label {
font-size: 12px;
color: var(--color-text-secondary);
font-size: 11px;
color: #6b6b6b;
margin: 0;
white-space: nowrap;
}
/* Content Cards */
.cards-row {
margin-bottom: 24px;
}
.content-card {
border-radius: var(--radius-card);
height: 100%;
}
.content-card .ant-card-head-title {
.kpi-value {
font-size: 15px;
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;
flex-direction: column;
justify-content: space-between;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
color: var(--color-text-secondary);
margin-bottom: 12px;
}
.placeholder-text p {
margin: 8px 0 4px;
.bar-chart-title {
font-size: 15px;
font-weight: 500;
color: #3b4a3b;
margin: 0;
}
.placeholder-text small {
color: #999;
}
/* Chart Container */
.chart-container {
padding: 8px 0;
.bar-chart-hint {
font-size: 11px;
color: #888780;
}
.bars-wrapper {
display: flex;
justify-content: space-between;
align-items: flex-end;
height: 200px;
padding: 0 8px;
height: 180px;
padding: 0 4px;
gap: 4px;
}
.bar-item {
@@ -151,41 +175,40 @@
flex-direction: column;
align-items: center;
flex: 1;
max-width: 40px;
max-width: 42px;
min-width: 20px;
justify-content: flex-end;
height: 180px;
}
.bar {
width: 24px;
border-radius: 4px 4px 0 0;
width: 100%;
max-width: 38px;
border-radius: 7px 7px 0 0;
transition: all 0.3s;
cursor: pointer;
position: relative;
}
.bar-label-top {
.bar-number {
font-size: 9px;
color: var(--color-text-secondary);
font-weight: 600;
text-align: center;
margin-top: 4px;
max-width: 40px;
white-space: nowrap;
margin-bottom: 2px;
}
.bar-program {
font-size: 10px;
color: #6b6b6b;
text-align: center;
max-width: 64px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.bar-label-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-top: 4px;
}
.bar-label-bottom span {
font-size: 9px;
color: var(--color-text-secondary);
}
/* Chart Legend */
.chart-legend {
display: flex;
justify-content: center;
@@ -200,11 +223,133 @@
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--color-text-secondary);
color: #6b6b6b;
}
.dot {
width: 8px;
height: 8px;
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;
}
+277 -146
View File
@@ -1,15 +1,20 @@
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 {
BarChartOutlined,
EyeOutlined,
CalendarOutlined,
FireOutlined,
CalendarOutlined,
BarChartOutlined,
PictureOutlined,
UploadOutlined,
LineChartOutlined,
AimOutlined,
} from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
import { listEpisodes } from '../../services/episodeService'
import { listTargets } from '../../services/yearlyTargetService'
import { getCurrentCover, getUpcomingSchedules } from '../../services/dashboardService'
import ChangeCoverModal from './ChangeCoverModal'
import './Dashboard.css'
/**
@@ -20,191 +25,317 @@ import './Dashboard.css'
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
*/
function getShareColor(share, targets, airYear) {
// 强制转数值,避免后端返回字符串导致数值比较失败
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 (isNaN(n)) return '#999'
const stretch = Number(target.stretch_target)
const base = Number(target.base_target)
if (n > stretch) return '#c0584f' // 红=超过摸高目标(优秀)
if (n >= base) return '#5b8db8' // 蓝=未达摸高目标(达标)
return '#7aa874' // 绿=未达基础目标(待提升)
if (n > stretch) return '#c0584f' // 红=超过摸高目标(优秀)
if (n >= base) return '#5b8db8' // 蓝=未达摸高目标(达标)
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) {
return Math.round((share / 1.0) * 180)
return Math.round((Number(share) / 1.0) * 160)
}
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() {
const { user } = useAuthStore()
const [episodes, setEpisodes] = 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 [coverModalOpen, setCoverModalOpen] = useState(false)
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian'
useEffect(() => {
Promise.all([
listEpisodes(9),
listEpisodes(50),
listTargets(),
]).then(([epData, tgtData]) => {
// 按 air_date 倒序取前 9
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date)).slice(0, 9)
getCurrentCover(),
getUpcomingSchedules(6),
]).then(([epData, tgtData, coverData, schedData]) => {
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date))
setEpisodes(sorted)
setTargets(tgtData || [])
setCover(coverData || { cover_path: null, episode_number: null, episode_title: null })
setSchedules(Array.isArray(schedData) ? schedData : [])
setLoading(false)
}).catch(() => {
setLoading(false)
})
}, [])
// 显示最近有份额数据的期次(最多9个,不够用真实数据补位
const hasShare = episodes.filter(e => e.audience_share != null)
const displayEpisodes = hasShare.length >= 5 ? hasShare.slice(0, 9) : episodes.slice(0, Math.max(9, hasShare.length || 5))
// 显示最近有份额数据的期次(最多12个
const displayEpisodes = episodes
.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 (
<div className="dashboard">
{/* 顶部 Banner */}
{/* ===== Banner ===== */}
<div className="dashboard-banner">
{/* 题图底图 或 默认渐变 */}
{cover.cover_path ? (
<img
src={cover.cover_path}
alt="题图"
className="banner-bg"
/>
) : (
<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">
<h2>本月收视最佳</h2>
{bestEpisode ? (
<p>
{bestEpisode.episode_number} {bestEpisode.program_name} ·
收视份额 {bestEpisode.audience_share}
</p>
) : (
<p>暂无收视数据</p>
)}
<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 className="banner-image-placeholder">
<div className="banner-placeholder-gradient" />
{showChangeCoverBtn && (
<Button
size="small"
icon={<PictureOutlined />}
disabled
className="change-cover-btn"
>
更换题图
</Button>
{/* 右下角换图按钮 */}
{showChangeCoverBtn && (
<Button
className="change-cover-btn"
icon={<PictureOutlined />}
onClick={() => setCoverModalOpen(true)}
>
更换题图
</Button>
)}
</div>
{/* ===== KPI 细条 ===== */}
<div className="kpi-strip">
{kpiData.map((item, i) => (
<div key={i} className="kpi-chip">
<div className="kpi-icon" style={{ background: item.bg }}>
{item.icon}
</div>
<div>
<p className="kpi-label">{item.label}</p>
<p className="kpi-value">{item.value}</p>
</div>
</div>
))}
</div>
{/* ===== 近9期收视柱图(全宽) ===== */}
<div className="bar-chart-card">
<div className="bar-chart-header">
<h3 className="bar-chart-title"> 12 期收视份额</h3>
<span className="bar-chart-hint">悬浮看编导/期次/收视率·颜色只对收视份额</span>
</div>
{loading ? (
<div className="chart-loading"><Spin size="small" /> 加载中...</div>
) : (
<>
<div className="bars-wrapper">
{displayEpisodes.map((ep) => {
const airYear = new Date(ep.air_date).getFullYear()
const color = ep.audience_share != null
? getShareColor(ep.audience_share, targets, airYear)
: '#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 (
<div key={ep.id} className="bar-item">
<div className="bar-number" style={{ color }}>
{ep.audience_share != null ? Number(ep.audience_share).toFixed(2) : '--'}
</div>
<Tooltip title={<span dangerouslySetInnerHTML={{ __html: tooltipContent }} />}>
<div
className="bar"
style={{
height: ep.audience_share != null ? getBarHeight(ep.audience_share) : 20,
backgroundColor: color,
}}
/>
</Tooltip>
<div className="bar-program" title={ep.program_name}>
{getShortTitle(ep.program_name)}
</div>
</div>
)
})}
</div>
<div className="chart-legend">
<span className="legend-item">
<span className="dot" style={{ background: '#7aa874' }}></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 className="bottom-row">
{/* 左:雷达占位 */}
<div className="side-card">
<h3 className="side-card-title">军事科技热点雷达</h3>
<div className="radar-placeholder">
<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>
<BarChartOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 4 }} />
<p>真实抓取 Phase 4c 实现</p>
<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>
)}
<span className="placeholder-label">敬请期待</span>
</div>
</div>
{/* KPI Cards */}
<Row gutter={16} className="kpi-row">
<Col span={8}>
<Card className="kpi-card">
<div className="kpi-icon" style={{ background: '#e8f5e9' }}>
<EyeOutlined style={{ color: '#6b8e6b', fontSize: 24 }} />
</div>
<div className="kpi-info">
<span className="kpi-value">{displayEpisodes.filter(e => e.audience_share).length || '--'}</span>
<span className="kpi-label"> 9 期已录</span>
</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 className="kpi-info">
<span className="kpi-value">
{bestEpisode ? (bestEpisode.audience_share * 100).toFixed(1) + '%' : '--'}
</span>
<span className="kpi-label">最佳收视份额</span>
</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>
{/* 三卡片区域 */}
<Row gutter={16} className="cards-row">
{/* 热点雷达 */}
<Col span={8}>
<Card className="content-card" title="热点雷达">
<div className="placeholder-text">
<BarChartOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
<p>热点雷达 · Phase 4c</p>
<small>编导个人首页核心功能Phase 4c 实施</small>
</div>
</Card>
</Col>
{/* 近 9 期收视柱图 */}
<Col span={8}>
<Card className="content-card" title="近 9 期收视" loading={loading}>
<div className="chart-container">
<div className="bars-wrapper">
{displayEpisodes.map((ep) => {
const airYear = new Date(ep.air_date).getFullYear()
const color = ep.audience_share != null
? getShareColor(ep.audience_share, targets, airYear)
: '#ccc'
return (
<div key={ep.id} className="bar-item">
<Tooltip title={`${ep.program_name} · ${ep.audience_share ?? '无数据'}`}>
<div
className="bar"
style={{
height: ep.audience_share != null ? getBarHeight(ep.audience_share) : 20,
backgroundColor: color,
}}
/>
</Tooltip>
<div className="bar-label-top">{getShortTitle(ep.program_name)}</div>
<div className="bar-label-bottom">
<Avatar size={20} style={{ fontSize: 10 }}>
{(ep.editor_name_snapshot || '?')[0]}
</Avatar>
<span>{ep.editor_name_snapshot || '未知'}</span>
</div>
</div>
)
})}
</div>
<div className="chart-legend">
<span className="legend-item"><span className="dot" style={{ background: '#7aa874' }}></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>
</Card>
</Col>
{/* 排播计划 */}
<Col span={8}>
<Card className="content-card" title="未来排播计划">
<div className="placeholder-text">
<CalendarOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
<p>排播计划 · Phase 4b</p>
<small>甘特图排期使用 frappe-gantt 实现Phase 4b 开发</small>
</div>
</Card>
</Col>
</Row>
{/* 换图 Modal */}
<ChangeCoverModal
open={coverModalOpen}
onClose={() => setCoverModalOpen(false)}
onUploaded={handleCoverUploaded}
episodes={episodes.slice(0, 20)}
/>
</div>
)
}
@@ -1,17 +1,423 @@
import { Card } from 'antd'
import { BookOutlined } from '@ant-design/icons'
import { useState, useEffect } from 'react'
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() {
return (
<Card style={{ borderRadius: 16 }}>
<div style={{ textAlign: 'center', padding: '48px 0', color: '#999' }}>
<BookOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<h3 style={{ color: '#666' }}>知识库</h3>
<p>模块 C · Phase 3 实施</p>
<small>往期报题单 / 文稿 / 军报语义检索</small>
</div>
</Card>
)
const { Dragger } = Upload
// source_type 枚举值(固定五类,不写死但供 Select 用)
const SOURCE_TYPE_OPTIONS = [
{ label: '全部类型', value: '' },
{ label: '杂志文章', value: 'military_report' },
{ label: '节目文稿', value: 'manuscript' },
{ label: '报题单', value: 'baoti' },
]
// 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>
)
}
+38
View File
@@ -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
}
+70
View File
@@ -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
+4
View File
@@ -10,6 +10,10 @@ export default defineConfig({
target: 'http://localhost:8000',
changeOrigin: true,
},
'/static': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
+197
View File
@@ -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 & pushcommit 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→authormilitary_report→source_detail,其余 None。
做成**可配置映射表**(前端 useKnowledgeGrouping.js + 后端 knowledge_service.py 同名映射),改一处即可调整。
⚠️ 此设计使"第二层维度"在不同大类下含义不同,是制片人知情拍板的临时方案;
将来宜把"按编导看稿"迁为独立筛选(见快照 Backlog#3)。
2. **点击筛选为前端本地计算**displayedItems 基于已拉取全量 items + 选中节点实时算,不发网络请求,稳定。
3. **过滤按大类用对应字段**manuscript 比 authormilitary_report 比 source_detailTask 3.2 最终修对的关键)。
4. **空大类不显示根因**:初始化时 type_groups 预建所有大类空列表,致 continue 失效;改为动态收集真实出现的 source_type。
### 改动文件
| 文件 | 动作 |
|------|------|
| `backend/app/services/knowledge_service.py` | 新增 get_grouped_items()SOURCE_TYPE_ORDERSECONDARY_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 内"语义搜索"尚未做(向量已就位,随时可开),是下一刀候选。
+116
View File
@@ -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 & pushcommit 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
View File
@@ -2,7 +2,7 @@
> 项目:TPS(Topic Planning System)中台 — 央视《军事科技》栏目内部工作台
> 仓库:`tps-dashboard`
> 修订版本:v4 · 2026-05-14
> 修订版本:v5 · 2026-05-27
> 制片人:刘通
> 协作大模型:Claude Opus 4.7(顾问)+ MiniMax M2.7(Plan + Act,Cline 内双角色物理隔离)
@@ -14,7 +14,7 @@
技术实施细节、阶段时间线在姊妹文档 `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 建议改"的设计都要重做。
**TPS 的产品定位(v5 明确)**:TPS 选题策划系统是一个**"带着栏目全部家底的策划助手"**,不是"主编的数字分身"。这半格之差是产品的命:
- **主编**会说"我建议你这么做"——带判断倾向,替编导拿主意。
- **助手**会说"顺着你这个思路,栏目里有这些相关家底,要不要我帮你理一理"——把判断的原料和脉络铺好,人来拍板。
TPS 做后者。它可以对话、可以加工、可以给有依据的分析("这个角度历史上收视偏低,可能和切入点有关"),但分析永远是**摆事实 + 给关联**,不是下结论("所以别做")。**AI 摆牌给编导看,牌由编导自己出。**
---
## 三、项目目标
@@ -91,17 +98,34 @@
## 五、产品功能模块(8 个)
### 模块 A — 选题查重(TPS 核心入口)
### 模块 A — 选题查重与对话式策划(TPS 核心入口)
**痛点**:编导一个选题,担心做过、想知道历史成绩。
**痛点**:编导一个创意或灵感,担心做过、想知道历史成绩;新编导面对一堆历史节目,不知道怎么用、看不出"so what"
**功能**:
- 编导输入选题简介 → 系统语义检索往期 `topics` + `episodes`
- 列出 5 条相似度最高的历史节目,**展示原文 + 当年收视成绩 + 颜色标识(红蓝绿)**
- 点开任一相似节目 → 联动模块 E,展示**外拍联系人 / 联系方式 / 线索来源**
- 编导自行判断:"这选题做过但收视很高可以重做"或"这个角度我们没碰过"
**形态(v5 升级——从"结果列表"升级为"对话式策划助手")**:
**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 — 编导画像建议
@@ -115,16 +139,19 @@
**1.0 形态**:粗框架,文字一段。
**2.0 形态**:动态画像,根据每期作品自动迭代。
### 模块 C — 知识库参考
### 模块 C — 知识库参考(策划报告的依据来源)
**痛点**:写选题时缺背景资料,需要去翻往期文稿、军报、专家观点。
**痛点**:写选题时缺背景资料,需要去翻往期文稿、军报、专家观点;更进一步,编导希望 AI 给的策划建议是"有出处的",不是凭空说
**功能**:
- 提交选题时,系统语义检索 `knowledge_items`(往期文稿 + 军报 + 内部资料)
- 列出 3-5 条最相关条目,展示摘要 + 关键词,可点开查看原文
- 编导可选择性引用,**引用关系入库**(为未来"知识溯源"打基础)
**形态(v5 升级)**:模块 C 是模块 A 右栏报告的**知识库依据供给侧**。
**关键技术**: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 — 历史审片意见展示
@@ -156,13 +183,22 @@
### 模块 G — 报题单生成与下载
**痛点**:报题单格式各编导各异,审核耗时。
**痛点**:报题单格式各编导各异,审核耗时;水平参差的编导写的报题单结构残缺、要素不全、漏洞百出
**形态(v5 明确)**:编导与 TPS 在模块 A 里聊定选题后,点一个按钮,把**编导已定的内容**按《军事科技》报题单格式一键生成 `.docx`,提供下载。
**功能**:
- 编导填好选题信息 + 选好相似参考 + 选好知识库引用 → 一键生成 `.docx` 报题单
- 模板由 docxtpl 渲染,**严格遵守"双段结构"**(引子 + 列举,见 `.clinerules` 第 5.4 节)
- AI 通过学习成熟报题单范本,帮助生成的报题单**更规范**:补齐结构、统一格式、提示遗漏要素、校对装备名/概念名——专治"漏洞百出"
- 编导可下载、修改、打印
**"一键生成"的分寸线(死规矩,不可越)**:
> 这个按钮做的是**"按范本规范化呈现编导已定的内容"**——排版 + 校对 + 结构补齐 + 遗漏提示。
> AI **不替编导生产选题立意、不代写内容**。规范化只作用于"格式、结构、要素齐全、字词准确"这一层;选题的内容和立意永远是编导的创作。
> 一旦 AI 在内容立意上动手,就从"助手排版"滑回了"代写",违背创作主权红线。
**落地顺序**:属 Phase 4a。
### 模块 H — 编导个人首页 + 热点雷达
**痛点**:编导想知道"今天有什么军事新闻可能值得做",目前靠各自刷微博/微信。
@@ -204,6 +240,18 @@
默认:**引子(200-300 字)+ 列举(3-5 条 Markdown 序号,每条以 `**加粗关键词**` 开头)**。
备选:纯叙述,**仅当编导明确指定才用**。
### 6.5 AI 摆牌不出牌(创作主权红线,v5 新增)
TPS 是"策划助手"不是"主编数字分身"(见第 2.3 节)。落到每一处 AI 交互:
- **摆事实、给关联、做有依据的分析**——可以。
- **下结论、给倾向、替编导拿主意**("建议放弃/建议这么改")——不可以。
- AI 的所有输出都是**"摆给编导看的牌"**,牌由编导自己出。
- 凡 AI 输出处,UI 必须给编导"忽略 / 我来改写"的否决权。
- 报题单"一键生成"只做规范化呈现,**不代写选题立意**(见模块 G 分寸线)。
> 自检口诀:这句 AI 的话,是"把原料铺给你看",还是"替你做了决定"?后者一律改掉。
---
## 七、技术栈与红线(精简版)
@@ -261,11 +309,11 @@
| Phase | 时间 | 核心交付 | 完成度 |
|---|---|---|---|
| Phase 0 | Day 1-2 | 环境搭建(Python/Node/PG/Git) | 家用机已完成,单位机重启 |
| Phase 1 | Week 1 | 后端 + 前端骨架 + 登录跑通 | 家用机后端已完成,前端待 |
| Phase 2 | Week 2-3 | 仪表盘 + 用户管理 + Excel 导入 | 未开始 |
| Phase 3 | Week 4 | 知识库基础设施 + 报题单导入 | 未开始 |
| Phase 4a | Week 5-6 | **MVP 上线**(报题单生成 + 查重 + 知识库参考) | 未开始 |
| Phase 0 | Day 1-2 | 环境搭建(Python/Node/PG/Git) | ✅ 已完成 |
| Phase 1 | Week 1 | 后端 + 前端骨架 + 登录跑通 | ✅ 已完成 |
| Phase 2 | Week 2-3 | 仪表盘 + 用户管理 + Excel 导入 | ✅ 已完成 |
| Phase 3 | Week 4 | 知识库基础设施 + 报题单导入 | 进行中(已完成至 Task 3 知识库树形视图;语义检索尚未做) |
| Phase 4a | Week 5-6 | **MVP 上线**(对话式 TPS + 报题单生成 + 知识库参考) | 未开始 |
| Phase 4b | Week 7-9 | 画像 + 甘特图 + 资源 | 未开始 |
| Phase 4c | Week 10-11 | 热点雷达 | 未开始 |
| 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 已由制片人申请就绪。
> 注意:上表是**开发期**的模型分工(谁帮我们写代码)。下文第十一章讲的是**产品运行期**的 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 里换模型 |
| 历史 Excel / docx 导入解析失败 | 高 | 解析失败的文件单独列出,提示责编手工修正后重传 |
| 编导抗拒使用新工具 | 中 | "辅助不替代"的产品哲学贯穿,绝不强制;先让最积极的 1-2 位编导先用,产生口碑 |
@@ -306,7 +380,7 @@
---
## 十、不在 1.0 范围内的事
## 十、不在 1.0 范围内的事
明确不做、留到 2.0 或更远的:
@@ -322,14 +396,15 @@
---
## 十、版本演进
## 十、版本演进
| 版本 | 日期 | 主要变化 |
|---|---|---|
| v1 | 2026-04 | 初版,定模块 A-G(7 个) |
| v2 | 2026-05-04 | 加模块 H 热点雷达,增 2.0 兼容预埋说明 |
| 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,数据灌入后)的落地顺序;清理过期风险项** |
---