diff --git a/platform/backend/app/api/auth.py b/platform/backend/app/api/auth.py
index 014b257..679d08b 100644
--- a/platform/backend/app/api/auth.py
+++ b/platform/backend/app/api/auth.py
@@ -7,8 +7,8 @@ import os
from dotenv import load_dotenv
from ..database import get_db
-from ..models import User
-from ..schemas import LoginRequest, TokenResponse, UserResponse
+from ..models import User, Role
+from ..schemas import LoginRequest, TokenResponse, UserResponse, ChangePasswordRequest
from ..core.audit_logger import audit_log
# 加载环境变量
@@ -169,3 +169,26 @@ def get_me(
"""获取当前登录用户信息"""
return {"user": UserResponse.model_validate(current_user)}
+@router.put("/password")
+def change_password(
+ data: ChangePasswordRequest,
+ request: Request,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user)
+):
+ """修改当前用户密码"""
+ if not bcrypt.checkpw(data.old_password.encode('utf-8'), current_user.password_hash.encode('utf-8')):
+ raise HTTPException(status_code=400, detail="原密码错误")
+ if len(data.new_password) < 6:
+ raise HTTPException(status_code=400, detail="新密码至少6位")
+ current_user.password_hash = bcrypt.hashpw(data.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
+ db.commit()
+ audit_log(
+ action="change_password",
+ user=current_user,
+ ip_address=request.client.host if request.client else None,
+ user_agent=request.headers.get("user-agent", ""),
+ db=db
+ )
+ return {"message": "密码修改成功"}
+
diff --git a/platform/backend/app/api/menu_configs.py b/platform/backend/app/api/menu_configs.py
new file mode 100644
index 0000000..3fda0d0
--- /dev/null
+++ b/platform/backend/app/api/menu_configs.py
@@ -0,0 +1,62 @@
+from fastapi import APIRouter, Depends, HTTPException, Request
+from sqlalchemy.orm import Session
+from typing import List
+
+from ..database import get_db
+from ..models import Menu, User
+from ..schemas import MenuCreate, MenuUpdate, MenuResponse
+from .auth import get_current_admin, get_current_user
+
+router = APIRouter(prefix="/api/admin/menus", tags=["admin"])
+public_router = APIRouter(prefix="/api/menus", tags=["menus"])
+
+@router.get("", response_model=List[MenuResponse])
+def list_menus(db: Session = Depends(get_db), admin_user: User = Depends(get_current_admin)):
+ menus = db.query(Menu).order_by(Menu.sort_order).all()
+ return [MenuResponse.model_validate(m) for m in menus]
+
+@public_router.get("/active", response_model=List[MenuResponse])
+def get_active_menus(request: Request, db: Session = Depends(get_db)):
+ try:
+ user = get_current_user(request, db)
+ role = user.role
+ except Exception:
+ role = None
+ menus = db.query(Menu).filter(Menu.is_active == True).order_by(Menu.sort_order).all()
+ result = []
+ for m in menus:
+ allowed_roles = m.roles or []
+ if not allowed_roles or (role and role in allowed_roles):
+ result.append(MenuResponse.model_validate(m))
+ return result
+
+@router.post("", response_model=MenuResponse)
+def create_menu(data: MenuCreate, request: Request, db: Session = Depends(get_db), admin_user: User = Depends(get_current_admin)):
+ menu = Menu(**data.model_dump())
+ db.add(menu)
+ db.commit()
+ db.refresh(menu)
+ return MenuResponse.model_validate(menu)
+
+@router.put("/{menu_id}", response_model=MenuResponse)
+def update_menu(menu_id: int, data: MenuUpdate, request: Request, db: Session = Depends(get_db), admin_user: User = Depends(get_current_admin)):
+ menu = db.query(Menu).filter(Menu.id == menu_id).first()
+ if not menu:
+ raise HTTPException(status_code=404, detail="菜单不存在")
+ update_data = data.model_dump(exclude_unset=True)
+ for key, val in update_data.items():
+ setattr(menu, key, val)
+ db.commit()
+ db.refresh(menu)
+ return MenuResponse.model_validate(menu)
+
+@router.delete("/{menu_id}")
+def delete_menu(menu_id: int, request: Request, db: Session = Depends(get_db), admin_user: User = Depends(get_current_admin)):
+ menu = db.query(Menu).filter(Menu.id == menu_id).first()
+ if not menu:
+ raise HTTPException(status_code=404, detail="菜单不存在")
+ if menu.children:
+ raise HTTPException(status_code=400, detail="请先删除子菜单")
+ db.delete(menu)
+ db.commit()
+ return {"message": "删除成功"}
diff --git a/platform/backend/app/api/role_configs.py b/platform/backend/app/api/role_configs.py
new file mode 100644
index 0000000..03017d5
--- /dev/null
+++ b/platform/backend/app/api/role_configs.py
@@ -0,0 +1,52 @@
+from fastapi import APIRouter, Depends, HTTPException, Request
+from sqlalchemy.orm import Session
+from typing import List
+
+from ..database import get_db
+from ..models import Role, User
+from ..schemas import RoleCreate, RoleUpdate, RoleResponse
+from .auth import get_current_admin
+
+router = APIRouter(prefix="/api/admin/roles", tags=["admin"])
+
+@router.get("", response_model=List[RoleResponse])
+def list_roles(db: Session = Depends(get_db), admin_user: User = Depends(get_current_admin)):
+ roles = db.query(Role).order_by(Role.name).all()
+ return [RoleResponse.model_validate(r) for r in roles]
+
+@router.post("", response_model=RoleResponse)
+def create_role(data: RoleCreate, request: Request, db: Session = Depends(get_db), admin_user: User = Depends(get_current_admin)):
+ existing = db.query(Role).filter(Role.name == data.name).first()
+ if existing:
+ raise HTTPException(status_code=400, detail="角色已存在")
+ role = Role(name=data.name, description=data.description, is_system=False)
+ db.add(role)
+ db.commit()
+ db.refresh(role)
+ return RoleResponse.model_validate(role)
+
+@router.put("/{role_id}", response_model=RoleResponse)
+def update_role(role_id: int, data: RoleUpdate, request: Request, db: Session = Depends(get_db), admin_user: User = Depends(get_current_admin)):
+ role = db.query(Role).filter(Role.id == role_id).first()
+ if not role:
+ raise HTTPException(status_code=404, detail="角色不存在")
+ if role.is_system:
+ raise HTTPException(status_code=400, detail="系统角色不可编辑")
+ if data.name is not None:
+ role.name = data.name
+ if data.description is not None:
+ role.description = data.description
+ db.commit()
+ db.refresh(role)
+ return RoleResponse.model_validate(role)
+
+@router.delete("/{role_id}")
+def delete_role(role_id: int, request: Request, db: Session = Depends(get_db), admin_user: User = Depends(get_current_admin)):
+ role = db.query(Role).filter(Role.id == role_id).first()
+ if not role:
+ raise HTTPException(status_code=404, detail="角色不存在")
+ if role.is_system:
+ raise HTTPException(status_code=400, detail="系统角色不可删除")
+ db.delete(role)
+ db.commit()
+ return {"message": "删除成功"}
diff --git a/platform/backend/app/database.py b/platform/backend/app/database.py
index a9a1f48..b6f3c32 100644
--- a/platform/backend/app/database.py
+++ b/platform/backend/app/database.py
@@ -104,6 +104,18 @@ def init_db():
("trend_field_mappings", "field_name", "VARCHAR"),
("trend_field_mappings", "sort_order", "INTEGER DEFAULT 0"),
("trend_field_mappings", "is_active", "BOOLEAN DEFAULT TRUE"),
+ ("roles", "id", "INTEGER PRIMARY KEY"),
+ ("roles", "name", "VARCHAR UNIQUE"),
+ ("roles", "description", "VARCHAR DEFAULT ''"),
+ ("roles", "is_system", "BOOLEAN DEFAULT FALSE"),
+ ("menus", "id", "INTEGER PRIMARY KEY"),
+ ("menus", "parent_id", "INTEGER REFERENCES menus(id)"),
+ ("menus", "name", "VARCHAR"),
+ ("menus", "path", "VARCHAR"),
+ ("menus", "icon", "VARCHAR DEFAULT ''"),
+ ("menus", "sort_order", "INTEGER DEFAULT 0"),
+ ("menus", "roles", "JSON DEFAULT '[]'::json"),
+ ("menus", "is_active", "BOOLEAN DEFAULT TRUE"),
]:
try:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}"))
@@ -112,6 +124,15 @@ def init_db():
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {typ}"))
except Exception:
pass
+ # Create roles and menus tables if they don't exist
+ for tbl_sql in [
+ "CREATE TABLE IF NOT EXISTS roles (id SERIAL PRIMARY KEY, name VARCHAR UNIQUE NOT NULL, description VARCHAR DEFAULT '', is_system BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now())",
+ "CREATE TABLE IF NOT EXISTS menus (id SERIAL PRIMARY KEY, parent_id INTEGER REFERENCES menus(id), name VARCHAR NOT NULL, path VARCHAR NOT NULL, icon VARCHAR DEFAULT '', sort_order INTEGER DEFAULT 0, roles JSON DEFAULT '[]'::json, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now())",
+ ]:
+ try:
+ conn.execute(text(tbl_sql))
+ except Exception:
+ pass
conn.commit()
except Exception:
pass # SQLite 不支持 IF NOT EXISTS,但 create_all 对 SQLite 够用,这里仅为 PostgreSQL 迁移
diff --git a/platform/backend/app/initial_data.py b/platform/backend/app/initial_data.py
index 7fe4039..f65473c 100644
--- a/platform/backend/app/initial_data.py
+++ b/platform/backend/app/initial_data.py
@@ -6,7 +6,7 @@ from .database import SessionLocal, init_db
from .models import (
Topic, TopicField, TopicConfigField, TopicStatusConfig,
User, Case, LLMConfig, SystemConfig, PlatformConfig,
- CollectorCategory, CollectorSource
+ CollectorCategory, CollectorSource, Role, Menu
)
import bcrypt
@@ -252,6 +252,29 @@ def import_initial_data():
db.commit()
print(f"✅ 导入 {len(cases_data)} 条案例")
+ # 初始化默认角色
+ if db.query(Role).count() == 0:
+ db.add(Role(name="admin", description="系统管理员", is_system=True))
+ db.add(Role(name="editor", description="编辑人员", is_system=True))
+ db.commit()
+ print("✅ 插入默认角色")
+
+ # 初始化默认菜单(与 uni-nav.js 对齐)
+ if db.query(Menu).count() == 0:
+ default_menus = [
+ {"name": "仪表盘", "path": "/", "icon": "IconHome", "sort_order": 0, "roles": ["admin", "editor"]},
+ {"name": "选题", "path": "topics.html", "icon": "IconTopic", "sort_order": 1, "roles": ["admin", "editor"]},
+ {"name": "数据", "path": "metrics.html", "icon": "IconData", "sort_order": 2, "roles": ["admin", "editor"]},
+ {"name": "日历", "path": "calendar.html", "icon": "IconCalendar", "sort_order": 3, "roles": ["admin", "editor"]},
+ {"name": "素材", "path": "assets.html", "icon": "IconFolder", "sort_order": 4, "roles": ["admin", "editor"]},
+ {"name": "任务", "path": "tasks.html", "icon": "IconMenu", "sort_order": 5, "roles": ["admin", "editor"]},
+ {"name": "系统", "path": "admin.html", "icon": "IconSetting", "sort_order": 6, "roles": ["admin"]},
+ ]
+ for m in default_menus:
+ db.add(Menu(**m))
+ db.commit()
+ print("✅ 插入默认菜单")
+
# 同步 PostgreSQL 自增序列
if os.getenv('USE_POSTGRES', 'true').lower() == 'true':
try:
diff --git a/platform/backend/app/main.py b/platform/backend/app/main.py
index b85ec15..a9916fe 100644
--- a/platform/backend/app/main.py
+++ b/platform/backend/app/main.py
@@ -9,7 +9,7 @@ from pathlib import Path
from .database import engine, get_db, init_db
from .models import Base
-from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items
+from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs
from .initial_data import import_initial_data
from .core.scheduler import scheduler
@@ -44,7 +44,10 @@ app.add_middleware(
)
# 初始化数据库
-Base.metadata.create_all(bind=engine)
+try:
+ Base.metadata.create_all(bind=engine)
+except Exception:
+ pass
init_db()
import_initial_data()
@@ -97,6 +100,9 @@ app.include_router(platform_config.router)
app.include_router(collector_mgmt.router)
app.include_router(assistant.router)
app.include_router(config_items.router)
+app.include_router(role_configs.router)
+app.include_router(menu_configs.router)
+app.include_router(menu_configs.public_router)
# 挂载自动生成的图片(必须先于前端根挂载)
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
diff --git a/platform/backend/app/models.py b/platform/backend/app/models.py
index 1efb5dc..083d9a5 100644
--- a/platform/backend/app/models.py
+++ b/platform/backend/app/models.py
@@ -57,6 +57,54 @@ class User(Base):
}
+class Role(Base):
+ __tablename__ = "roles"
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ name = Column(String, unique=True, nullable=False)
+ description = Column(String, default="")
+ is_system = Column(Boolean, default=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "name": self.name,
+ "description": self.description,
+ "is_system": self.is_system,
+ "created_at": self.created_at.isoformat() if self.created_at else None
+ }
+
+
+class Menu(Base):
+ __tablename__ = "menus"
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ parent_id = Column(Integer, ForeignKey("menus.id"), nullable=True)
+ name = Column(String, nullable=False)
+ path = Column(String, nullable=False)
+ icon = Column(String, default="")
+ sort_order = Column(Integer, default=0)
+ roles = Column(JSON, default=list)
+ is_active = Column(Boolean, default=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ parent = relationship("Menu", remote_side=[id], backref="children")
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "parent_id": self.parent_id,
+ "name": self.name,
+ "path": self.path,
+ "icon": self.icon,
+ "sort_order": self.sort_order,
+ "roles": self.roles or [],
+ "is_active": self.is_active,
+ "created_at": self.created_at.isoformat() if self.created_at else None
+ }
+
+
class TopicField(Base):
__tablename__ = "topic_fields"
diff --git a/platform/backend/app/schemas.py b/platform/backend/app/schemas.py
index bbca844..d9c9a93 100644
--- a/platform/backend/app/schemas.py
+++ b/platform/backend/app/schemas.py
@@ -389,11 +389,18 @@ class UserUpdate(BaseModel):
class UserResponse(UserBase):
id: int
org_id: Optional[str] = None
+ last_login: Optional[datetime] = None
created_at: Optional[datetime] = None
+ updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
+class ChangePasswordRequest(BaseModel):
+ old_password: str
+ new_password: str
+
+
class LoginRequest(BaseModel):
username: str
password: str
@@ -545,3 +552,56 @@ class SystemStatus(BaseModel):
execution_time: Optional[float] = None
+class RoleBase(BaseModel):
+ name: str
+ description: str = ""
+ is_system: bool = False
+
+
+class RoleCreate(RoleBase):
+ pass
+
+
+class RoleUpdate(BaseModel):
+ name: Optional[str] = None
+ description: Optional[str] = None
+
+
+class RoleResponse(RoleBase):
+ id: int
+ created_at: Optional[datetime] = None
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class MenuBase(BaseModel):
+ parent_id: Optional[int] = None
+ name: str
+ path: str
+ icon: str = ""
+ sort_order: int = 0
+ roles: List[str] = []
+ is_active: bool = True
+
+
+class MenuCreate(MenuBase):
+ pass
+
+
+class MenuUpdate(BaseModel):
+ parent_id: Optional[int] = None
+ name: Optional[str] = None
+ path: Optional[str] = None
+ icon: Optional[str] = None
+ sort_order: Optional[int] = None
+ roles: Optional[List[str]] = None
+ is_active: Optional[bool] = None
+
+
+class MenuResponse(MenuBase):
+ id: int
+ created_at: Optional[datetime] = None
+
+ model_config = ConfigDict(from_attributes=True)
+
+
diff --git a/platform/frontend/admin.html b/platform/frontend/admin.html
index c5491a1..ce6a7f8 100644
--- a/platform/frontend/admin.html
+++ b/platform/frontend/admin.html
@@ -23,11 +23,12 @@
系统管理
用户管理
- 任务日志
LLM配置
平台配置
系统配置
组织管理
+ 角色管理
+ 菜单管理
运行日志
AI 助手
@@ -47,11 +48,23 @@
-
-
-
-
+
+
+
+ {{ scope.row.role }}
+
+
+
+
+ {{ scope.row.created_at ? new Date(scope.row.created_at).toLocaleString('zh-CN') : '-' }}
+
+
+ {{ scope.row.last_login ? new Date(scope.row.last_login).toLocaleString('zh-CN') : '-' }}
+
+
+
+ 编辑
删除
@@ -64,16 +77,19 @@
用户名{{ item.username }}
角色{{ item.role }}
+
组织{{ item.org_id }}
+
创建{{ item.created_at ? new Date(item.created_at).toLocaleDateString('zh-CN') : '-' }}
+ 编辑
删除
-
+
-
-
+
+
@@ -83,45 +99,11 @@
取消
- 创建
+ {{ userDialogTitle === '新增用户' ? '创建' : '保存' }}
-
-
- 刷新
-
-
加载中...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
任务{{ item.task_name }}
-
选题{{ item.topic_id }}
-
状态{{ item.status }}
-
消息{{ item.message }}
-
耗时{{ item.duration }}s
-
-
-
-
新增配置
@@ -154,7 +136,7 @@
{{ (scope.row.system_prompt || '').slice(0, 30) }}{{ (scope.row.system_prompt || '').length > 30 ? '...' : '' }}
-
+
编辑
删除
@@ -219,9 +201,10 @@
{{ scope.row.is_active ? '是' : '否' }}
-
+
编辑
+ 删除
@@ -260,6 +243,7 @@
+
新增配置
@@ -353,6 +337,108 @@
+
+
+ 新增角色
+ 共 {{ roles.length }} 个
+
+
加载中...
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+ 取消
+ {{ roleDialogTitle === '新增角色' ? '创建' : '保存' }}
+
+
+
+
+
+
+ 新增菜单
+ 共 {{ menus.length }} 个
+
+
加载中...
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ r }}
+ 全部
+
+
+
+
+
+
+
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ {{ menuDialogTitle === '新增菜单' ? '创建' : '保存' }}
+
+
+
+
@@ -499,21 +585,12 @@
const redirectToPage = (page) => { window.location.href = page.startsWith('/') ? page : '/' + page; };
- const taskLogs = ref([]);
- const taskLogsLoading = ref(false);
- const loadTaskLogs = async () => {
- taskLogsLoading.value = true;
- try { taskLogs.value = await api.get('/api/admin/tasklogs'); } catch (e) { ElMessage.error('加载任务日志失败: ' + e.message); }
- finally { taskLogsLoading.value = false; }
- };
-
- const llmConfigs = ref([]);
+const llmConfigs = ref([]);
const llmConfigsLoading = ref(false);
const llmConfigDialogVisible = ref(false);
- const llmConfigDialogTitle = ref('新增配置');
+ const llmConfigDialogTitle = ref('新增 LLM 配置');
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 131072, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true });
const editingLLMConfigId = ref(null);
-
const loadLLMConfigs = async () => {
llmConfigsLoading.value = true;
try { llmConfigs.value = await api.get('/api/admin/llmconfigs'); } catch (e) { ElMessage.error('加载LLM配置失败: ' + e.message); }
@@ -522,14 +599,10 @@
const showLLMConfigDialog = (row = null) => {
if (row) {
llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id;
- llmConfigForm.id = row.id; llmConfigForm.name = row.name; llmConfigForm.provider = row.provider || 'opencode-go';
- llmConfigForm.model = row.model || ''; llmConfigForm.base_url = row.base_url || ''; llmConfigForm.api_key = '';
- llmConfigForm.temperature = row.temperature ?? 0.7; llmConfigForm.max_tokens = row.max_tokens ?? 131072;
- llmConfigForm.system_prompt = row.system_prompt || ''; llmConfigForm.user_prompt_template = row.user_prompt_template || '';
- llmConfigForm.is_active = row.is_active ?? true;
+ Object.assign(llmConfigForm, row);
} else {
llmConfigDialogTitle.value = '新增配置'; editingLLMConfigId.value = null;
- llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'opencode-go'; llmConfigForm.model = 'deepseek-v4-pro';
+ llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'opencode-go'; llmConfigForm.model = 'deepseek-v4-pro';
llmConfigForm.base_url = 'https://opencode.ai/zen/go/v1'; llmConfigForm.api_key = ''; llmConfigForm.temperature = 0.7;
llmConfigForm.max_tokens = 131072; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true;
}
@@ -550,42 +623,38 @@
const platformConfigs = ref([]);
const platformConfigsLoading = ref(false);
const platformConfigsRequested = ref(false);
- const pcShowActiveOnly = ref(true);
- const showPcDialog = ref(false);
- const pcDialogTitle = ref('新增配置');
+ const pcShowActiveOnly = ref(false);
const pcForm = ref({ platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true });
+ const pcDialogTitle = ref('新增平台');
const editingPcPlatform = ref(null);
- const loadPlatformConfigs = async () => {
- platformConfigsLoading.value = true;
- platformConfigsRequested.value = true;
- try {
- const url = pcShowActiveOnly.value ? '/api/platform-config?active_only=true' : '/api/platform-config';
- const data = await api.get(url);
- platformConfigs.value = data;
- } catch (e) {
- ElMessage.error('加载平台配置失败: ' + e.message);
- }
- finally { platformConfigsLoading.value = false; }
- };
+ const showPcDialog = ref(false);
const showPcDialogFn = (row = null) => {
- try {
- if (row) {
- pcDialogTitle.value = '编辑配置'; editingPcPlatform.value = row.platform;
- pcForm.value = { ...row };
- } else {
- pcDialogTitle.value = '新增配置'; editingPcPlatform.value = null;
- pcForm.value = { platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true };
- }
- showPcDialog.value = true;
- } catch (e) { console.error('showPcDialogFn error:', e); }
+ if (row) { pcDialogTitle.value = '编辑平台'; editingPcPlatform.value = row.platform; pcForm.value = { ...row }; }
+ else { pcDialogTitle.value = '新增平台'; editingPcPlatform.value = null; pcForm.value = { platform: '', name: '', icon: '', website_url: '', api_endpoint: '', min_words: 300, max_words: 3000, requires_image: false, image_count_min: 0, image_count_max: 0, default_format: '', is_active: true }; }
+ showPcDialog.value = true;
};
const savePcForm = async () => {
try {
- if (editingPcPlatform.value) { await api.put(`/api/platform-config/${pcForm.value.platform}`, pcForm.value); ElMessage.success('更新成功'); }
+ if (editingPcPlatform.value) { await api.put(`/api/platform-config/${editingPcPlatform.value}`, pcForm.value); ElMessage.success('更新成功'); }
else { await api.post('/api/platform-config', pcForm.value); ElMessage.success('创建成功'); }
showPcDialog.value = false; await loadPlatformConfigs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
+ const loadPlatformConfigs = async () => {
+ platformConfigsLoading.value = true;
+ try { platformConfigs.value = await api.get('/api/platform-config'); } catch (e) { ElMessage.error('加载平台配置失败: ' + e.message); }
+ finally { platformConfigsLoading.value = false; platformConfigsRequested.value = true; }
+ };
+ const deletePlatformConfig = async (platform) => {
+ try {
+ await ElMessageBox.confirm(`确定要停用平台配置「${platform}」吗?`, '确认删除', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' });
+ await api.delete(`/api/platform-config/${platform}`);
+ ElMessage.success('已停用');
+ await loadPlatformConfigs();
+ } catch (e) {
+ if (e !== 'cancel') ElMessage.error('删除失败: ' + (e.message || e));
+ }
+ };
const systemConfigs = ref([]);
const systemConfigsLoading = ref(false);
@@ -600,7 +669,12 @@
const orgDialogTitle = ref('新增组织');
const orgForm = reactive({ org_id: '', name: '', description: '' });
const editingOrgId = ref(null);
-
+ const orgPage = ref(1);
+ const orgPageSize = 10;
+ const paginatedOrgs = computed(() => {
+ const start = (orgPage.value - 1) * orgPageSize;
+ return orgs.value.slice(start, start + orgPageSize);
+ });
const loadOrgs = async () => {
orgsLoading.value = true;
try { orgs.value = await api.get('/api/admin/orgs'); } catch (e) { ElMessage.error('加载组织失败: ' + e.message); }
@@ -612,26 +686,87 @@
orgDialogVisible.value = true;
};
const saveOrg = async () => {
- if (!orgForm.org_id || !orgForm.name) { ElMessage.warning('请填写组织ID和名称'); return; }
try {
if (editingOrgId.value) { await api.put(`/api/admin/orgs/${editingOrgId.value}`, orgForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/orgs', orgForm); ElMessage.success('创建成功'); }
orgDialogVisible.value = false; await loadOrgs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
- const deleteOrg = async (orgId) => {
- try { await ElMessageBox.confirm('确定删除该组织吗?关联的用户和选题不会被删除。', '提示', { type: 'warning' }); await api.delete(`/api/admin/orgs/${orgId}`); ElMessage.success('删除成功'); await loadOrgs(); }
+ const deleteOrg = async (id) => {
+ try { await ElMessageBox.confirm('确定删除该组织吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/orgs/${id}`); ElMessage.success('删除成功'); await loadOrgs(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
- const orgPage = ref(1);
- const orgPageSize = 10;
- const paginatedOrgs = computed(() => { const s = (orgPage.value - 1) * orgPageSize; return orgs.value.slice(s, s + orgPageSize); });
+ const roles = ref([]);
+ const rolesLoading = ref(false);
+ const roleDialogVisible = ref(false);
+ const roleDialogTitle = ref('新增角色');
+ const roleSaving = ref(false);
+ const roleForm = reactive({ id: null, name: '', description: '' });
+ const loadRoles = async () => {
+ rolesLoading.value = true;
+ try { roles.value = await api.get('/api/admin/roles'); } catch (e) { ElMessage.error('加载角色失败: ' + e.message); }
+ finally { rolesLoading.value = false; }
+ };
+ const showRoleDialog = (row = null) => {
+ if (row) { roleDialogTitle.value = '编辑角色'; roleForm.id = row.id; roleForm.name = row.name; roleForm.description = row.description || ''; }
+ else { roleDialogTitle.value = '新增角色'; roleForm.id = null; roleForm.name = ''; roleForm.description = ''; }
+ roleDialogVisible.value = true;
+ };
+ const saveRole = async () => {
+ if (!roleForm.name) { ElMessage.warning('请输入角色名称'); return; }
+ roleSaving.value = true;
+ try {
+ if (roleForm.id) { await api.put(`/api/admin/roles/${roleForm.id}`, { name: roleForm.name, description: roleForm.description }); ElMessage.success('更新成功'); }
+ else { await api.post('/api/admin/roles', { name: roleForm.name, description: roleForm.description }); ElMessage.success('创建成功'); }
+ roleDialogVisible.value = false; await loadRoles();
+ } catch (e) { ElMessage.error('保存失败: ' + e.message); }
+ finally { roleSaving.value = false; }
+ };
+ const deleteRole = async (id) => {
+ try { await ElMessageBox.confirm('确定删除该角色吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/roles/${id}`); ElMessage.success('删除成功'); await loadRoles(); }
+ catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
+ };
+
+ const menus = ref([]);
+ const menusLoading = ref(false);
+ const menuDialogVisible = ref(false);
+ const menuDialogTitle = ref('新增菜单');
+ const menuSaving = ref(false);
+ const menuForm = reactive({ id: null, name: '', path: '', icon: '', sort_order: 0, roles: [], is_active: true });
+ const loadMenus = async () => {
+ menusLoading.value = true;
+ try { menus.value = await api.get('/api/admin/menus'); } catch (e) { ElMessage.error('加载菜单失败: ' + e.message); }
+ finally { menusLoading.value = false; }
+ };
+ const showMenuDialog = (row = null) => {
+ if (row) { menuDialogTitle.value = '编辑菜单'; menuForm.id = row.id; menuForm.name = row.name; menuForm.path = row.path; menuForm.icon = row.icon || ''; menuForm.sort_order = row.sort_order || 0; menuForm.roles = row.roles || []; menuForm.is_active = row.is_active !== false; }
+ else { menuDialogTitle.value = '新增菜单'; menuForm.id = null; menuForm.name = ''; menuForm.path = ''; menuForm.icon = ''; menuForm.sort_order = 0; menuForm.roles = []; menuForm.is_active = true; }
+ menuDialogVisible.value = true;
+ };
+ const saveMenu = async () => {
+ if (!menuForm.name || !menuForm.path) { ElMessage.warning('请填写名称和路径'); return; }
+ menuSaving.value = true;
+ try {
+ const body = { name: menuForm.name, path: menuForm.path, icon: menuForm.icon, sort_order: menuForm.sort_order, roles: menuForm.roles, is_active: menuForm.is_active };
+ if (menuForm.id) { await api.put(`/api/admin/menus/${menuForm.id}`, body); ElMessage.success('更新成功'); }
+ else { await api.post('/api/admin/menus', body); ElMessage.success('创建成功'); }
+ menuDialogVisible.value = false; await loadMenus();
+ } catch (e) { ElMessage.error('保存失败: ' + e.message); }
+ finally { menuSaving.value = false; }
+ };
+ const deleteMenu = async (id) => {
+ try { await ElMessageBox.confirm('确定删除该菜单吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/menus/${id}`); ElMessage.success('删除成功'); await loadMenus(); }
+ catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
+ };
+
+ const logType = ref('creator');
const users = ref([]);
const usersLoading = ref(false);
const userDialogVisible = ref(false);
+ const userDialogTitle = ref('新增用户');
const userSubmitting = ref(false);
- const userForm = reactive({ username: '', password: '', role: 'editor' });
+ const userForm = reactive({ id: null, username: '', password: '', role: 'editor' });
const userPage = ref(1);
const userPageSize = 10;
const paginatedUsers = computed(() => {
@@ -643,17 +778,24 @@
try { users.value = await api.get('/api/admin/users'); } catch (e) { ElMessage.error('加载用户失败: ' + e.message); }
finally { usersLoading.value = false; }
};
- const addUser = () => { userForm.username = ''; userForm.password = ''; userForm.role = 'editor'; userDialogVisible.value = true; };
+ const addUser = () => { userDialogTitle.value = '新增用户'; userForm.id = null; userForm.username = ''; userForm.password = ''; userForm.role = 'editor'; userDialogVisible.value = true; };
+ const showEditUserDialog = (row) => { userDialogTitle.value = '编辑用户'; userForm.id = row.id; userForm.username = row.username; userForm.password = ''; userForm.role = row.role; userDialogVisible.value = true; };
const submitUser = async () => {
if (!userForm.username || userForm.username.length < 2) { ElMessage.warning('用户名至少2个字符'); return; }
- if (!userForm.password || userForm.password.length < 6) { ElMessage.warning('密码至少6位'); return; }
+ if (userDialogTitle.value === '新增用户' && (!userForm.password || userForm.password.length < 6)) { ElMessage.warning('密码至少6位'); return; }
userSubmitting.value = true;
try {
- const u = await api.post('/api/admin/users', { ...userForm });
- users.value.push(u);
- ElMessage.success('创建成功');
- userDialogVisible.value = false;
- } catch (e) { ElMessage.error('创建失败: ' + e.message); }
+ if (userForm.id) {
+ const body = { username: userForm.username, role: userForm.role };
+ if (userForm.password) body.password = userForm.password;
+ await api.put(`/api/admin/users/${userForm.id}`, body);
+ ElMessage.success('更新成功');
+ } else {
+ await api.post('/api/admin/users', { ...userForm });
+ ElMessage.success('创建成功');
+ }
+ userDialogVisible.value = false; await fetchUsers();
+ } catch (e) { ElMessage.error((userForm.id ? '更新' : '创建') + '失败: ' + e.message); }
finally { userSubmitting.value = false; }
};
const deleteUser = async (id) => {
@@ -665,8 +807,6 @@
ElMessage.success('删除成功');
} catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
-
- const logType = ref('creator');
const logDate = ref(new Date().toISOString().slice(0, 10));
const logContent = ref('');
const logsLoading = ref(false);
@@ -735,10 +875,9 @@
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
const tabLoaders = {
- tasklogs: loadTaskLogs,
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
users: fetchUsers,
- orgs: loadOrgs, assistant: loadAssistantConfig,
+ orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig, logs: fetchLogs,
};
const loadedTabs = new Set([]);
@@ -760,15 +899,16 @@
return {
activeTab, switchTab,
- taskLogs, taskLogsLoading, loadTaskLogs,
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
platformConfigs, platformConfigsLoading, platformConfigsRequested, pcShowActiveOnly, showPcDialog, pcForm, pcDialogTitle, showPcDialogFn, savePcForm, editingPcPlatform,
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
orgPage, orgPageSize, paginatedOrgs,
+ roles, rolesLoading, roleDialogVisible, roleDialogTitle, roleSaving, roleForm, loadRoles, showRoleDialog, saveRole, deleteRole,
+ menus, menusLoading, menuDialogVisible, menuDialogTitle, menuSaving, menuForm, loadMenus, showMenuDialog, saveMenu, deleteMenu,
logout, currentUser, isAdmin, redirectToPage,
- users, usersLoading, userDialogVisible, userSubmitting, userForm, userPage, userPageSize, paginatedUsers,
- fetchUsers, addUser, submitUser, deleteUser,
+ users, usersLoading, userDialogVisible, userDialogTitle, userSubmitting, userForm, userPage, userPageSize, paginatedUsers,
+ fetchUsers, addUser, showEditUserDialog, submitUser, deleteUser,
logType, logDate, logContent, logsLoading, fetchLogs,
assistantPrompt, assistantEnabled, assistantSaving, saveAssistantPrompt,
};
diff --git a/platform/frontend/articles.html b/platform/frontend/articles.html
index 43c445a..512f0b3 100644
--- a/platform/frontend/articles.html
+++ b/platform/frontend/articles.html
@@ -106,7 +106,7 @@
-
+
diff --git a/platform/frontend/index.html b/platform/frontend/index.html
index 7a89d9a..e3a8e4f 100644
--- a/platform/frontend/index.html
+++ b/platform/frontend/index.html
@@ -19,7 +19,8 @@
.module-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-md); }
.module-title { font-size: var(--font-size-h3); font-weight: 600; color: var(--color-text-primary); display: flex; align-items: center; gap: var(--spacing-sm); }
.module-status { padding: 6px 12px; border-radius: var(--radius-xl); font-size: var(--font-size-caption); font-weight: 600; background: rgba(103,194,58,0.12); color: #67c23a; border: 1px solid rgba(103,194,58,0.25); }
- .module-status.running { animation: pulse-glow 2s infinite; }
+ .module-status.inactive { background: rgba(144,147,153,0.12); color: #909399; border: 1px solid rgba(144,147,153,0.25); }
+ .module-drawer .el-drawer__body { padding: 20px; overflow-y: auto; }
.module-content { font-size: var(--font-size-body); color: var(--color-text-regular); line-height: 1.8; }
.module-content div { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dashed var(--color-border); }
.module-content div:last-child { border-bottom: none; }
@@ -95,18 +96,31 @@
-
+
-
最后运行{{ mod.last_run }}
-
下次运行{{ mod.next_run }}
-
今日任务{{ mod.task_count }} 个
-
成功率{{ mod.success_rate > 0 ? mod.success_rate + '%' : '暂无' }}
+
执行时间{{ mod.schedule || mod.cron_default }}
+
成功/失败{{ mod.success_runs }} / {{ mod.failed_runs }}
+
今日任务{{ mod.total_runs }} 次
+
下次运行{{ mod.next_run || '—' }}
+
+ 最新产出
+ {{ mod.last_result.topics_count }} 个选题
+
+
+ 最新产出
+ {{ mod.last_result.articles_synced }} 篇
+
- 立即运行
+ 详情
+ 立即运行
@@ -132,6 +146,56 @@
+
+
+
+ 加载中...
+
+
+ {{ moduleDetail.enabled ? '已启用' : '已禁用' }}
+ 执行时间: {{ moduleDetail.schedule || moduleDetail.cron_default }}
+
+
+
+
+
+
+
+ {{ h.status }}
+ {{ h.message }}
+ —
+
+ {{ h.error_trace }}
+ 耗时 {{ h.duration ? h.duration + 's' : '—' }} · {{ h.triggered_by }}
+
+
+
+ 暂无运行记录
+
+
+
+
+
+
+
{{ p.category }}
+
{{ p.key }}
+
{{ p.description }}
+
+
+
+
+
+
+ 此模块暂无提示词配置
+
+
+
+ 立即运行此任务
+
+
+
@@ -163,7 +227,13 @@
schedulerRunning: false,
upcomingEntries: [],
loadingPlan: false,
- runningModule: null
+ runningModule: null,
+ showModuleDrawer: false,
+ moduleDetail: null,
+ moduleDetailLoading: false,
+ moduleDetailHistory: [],
+ moduleDetailPrompts: [],
+ promptSaving: null
};
},
methods: {
@@ -311,6 +381,40 @@
},
redirectToPage(page) {
window.location.href = page.startsWith('/') ? page : '/' + page;
+ },
+ async openModuleDetail(mod) {
+ this.showModuleDrawer = true;
+ this.moduleDetail = mod;
+ this.moduleDetailLoading = true;
+ this.moduleDetailHistory = [];
+ this.moduleDetailPrompts = [];
+ const token = localStorage.getItem('authToken');
+ try {
+ const [history, prompts] = await Promise.all([
+ fetch('/api/admin/task-configs/history/' + mod.module_id + '?limit=20', {
+ headers: { 'Authorization': 'Bearer ' + token }
+ }).then(r => r.ok ? r.json() : []),
+ fetch('/api/admin/prompt-configs?module_id=' + mod.module_id, {
+ headers: { 'Authorization': 'Bearer ' + token }
+ }).then(r => r.ok ? r.json() : [])
+ ]);
+ this.moduleDetailHistory = history || [];
+ this.moduleDetailPrompts = prompts || [];
+ } catch (e) { this.$message.error('加载详情失败: ' + e.message); }
+ finally { this.moduleDetailLoading = false; }
+ },
+ async savePrompt(p) {
+ this.promptSaving = p.id;
+ const token = localStorage.getItem('authToken');
+ try {
+ await fetch('/api/admin/prompt-configs/' + p.id, {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: p.content, enabled: p.enabled, description: p.description, temperature: p.temperature, max_tokens: p.max_tokens })
+ });
+ this.$message.success('已保存');
+ } catch (e) { this.$message.error('保存失败: ' + e.message); }
+ finally { this.promptSaving = null; }
}
},
mounted() {
diff --git a/platform/frontend/uni-nav.js b/platform/frontend/uni-nav.js
index 78fc309..9f2d866 100644
--- a/platform/frontend/uni-nav.js
+++ b/platform/frontend/uni-nav.js
@@ -153,10 +153,24 @@
},
emits: ['logout'],
data: function () {
- return { dropdownOpen: false };
+ return {
+ dropdownOpen: false,
+ profileOpen: false,
+ showProfileDialog: false,
+ showPwdDialog: false,
+ profile: null,
+ pwdForm: { old_password: '', new_password: '', confirm: '' },
+ pwdSubmitting: false,
+ serverMenus: null,
+ };
},
computed: {
items: function () {
+ if (this.serverMenus) {
+ return this.serverMenus
+ .filter(function (m) { return m.is_active !== false; })
+ .map(function (m) { return { key: m.name, label: m.name, page: m.path }; });
+ }
return navItems(this.isAdmin);
},
page: function () {
@@ -167,31 +181,79 @@
},
},
methods: {
+ fetchMenus: function () {
+ var token = localStorage.getItem('authToken');
+ if (!token) return;
+ fetch('/api/menus/active', { headers: { 'Authorization': 'Bearer ' + token } })
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
+ .then(function (data) {
+ if (data && data.length > 0) this.serverMenus = data;
+ }.bind(this))
+ .catch(function () {});
+ },
navigate: function (page) {
- this.dropdownOpen = false;
+ this.dropdownOpen = false; this.profileOpen = false;
if (this.onNavigate) { this.onNavigate(page); return; }
window.location.href = page === '/' ? '/' : '/' + page;
},
logout: function () {
+ this.profileOpen = false;
this.$emit('logout');
},
toggleDropdown: function () {
- this.dropdownOpen = !this.dropdownOpen;
+ this.dropdownOpen = !this.dropdownOpen; this.profileOpen = false;
},
- closeDropdown: function (e) {
- if (this.dropdownOpen && !this.$el.contains(e.target)) {
- this.dropdownOpen = false;
- }
+ toggleProfile: function () {
+ this.profileOpen = !this.profileOpen; this.dropdownOpen = false;
},
+ closeAll: function (e) {
+ var el = this.$el;
+ if (!el) return;
+ if (this.dropdownOpen && !el.contains(e.target)) this.dropdownOpen = false;
+ if (this.profileOpen && !el.querySelector('.uni-nav-profile-menu')?.contains(e.target) && e.target !== el.querySelector('.uni-nav-avatar') && !el.querySelector('.uni-nav-avatar')?.contains(e.target)) this.profileOpen = false;
+ },
+ openProfile: function () {
+ this.profileOpen = false;
+ var token = localStorage.getItem('authToken');
+ if (!token) return;
+ fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
+ .then(function (d) { this.profile = d.user || d; this.showProfileDialog = true; }.bind(this))
+ .catch(function () { (ElementPlus.ElMessage || {}).error && ElementPlus.ElMessage.error('获取用户信息失败'); });
+ },
+ openPwdDialog: function () {
+ this.profileOpen = false;
+ this.pwdForm = { old_password: '', new_password: '', confirm: '' };
+ this.showPwdDialog = true;
+ },
+ submitPwd: function () {
+ if (this.pwdForm.new_password.length < 6) { (ElementPlus.ElMessage || {}).warning && ElementPlus.ElMessage.warning('新密码至少6位'); return; }
+ if (this.pwdForm.new_password !== this.pwdForm.confirm) { (ElementPlus.ElMessage || {}).warning && ElementPlus.ElMessage.warning('两次密码不一致'); return; }
+ this.pwdSubmitting = true;
+ var token = localStorage.getItem('authToken');
+ fetch('/api/auth/password', {
+ method: 'PUT',
+ headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ old_password: this.pwdForm.old_password, new_password: this.pwdForm.new_password })
+ }).then(function (r) {
+ if (r.ok) { this.showPwdDialog = false; (ElementPlus.ElMessage || {}).success && ElementPlus.ElMessage.success('密码修改成功'); }
+ else { return r.json().then(function (d) { throw new Error(d.detail || '修改失败'); }); }
+ }.bind(this)).catch(function (e) {
+ (ElementPlus.ElMessage || {}).error && ElementPlus.ElMessage.error(e.message);
+ }).finally(function () { this.pwdSubmitting = false; }.bind(this));
+ },
+ closePwdDialog: function () { this.showPwdDialog = false; },
+ closeProfileDialog: function () { this.showProfileDialog = false; },
},
mounted: function () {
- document.addEventListener('click', this.closeDropdown);
+ document.addEventListener('click', this.closeAll);
+ this.fetchMenus();
},
beforeUnmount: function () {
- document.removeEventListener('click', this.closeDropdown);
+ document.removeEventListener('click', this.closeAll);
},
template: '\
-
\
+
\
+
\
+
个人信息
\
+
\
+
用户名{{ profile ? profile.username : \'-\' }}
\
+
角色{{ profile ? profile.role : \'-\' }}
\
+
组织{{ profile ? (profile.org_id || \'-\') : \'-\' }}
\
+
创建时间{{ profile && profile.created_at ? new Date(profile.created_at).toLocaleString(\'zh-CN\') : \'-\' }}
\
+
最后登录{{ profile && profile.last_login ? new Date(profile.last_login).toLocaleString(\'zh-CN\') : \'-\' }}
\
+
\
+
\
+
\
+
\
+
\
+
\
+
修改密码
\
+
\
+
\
+ \
+ \
+
\
+
\
+
',
};
window.UniNav = UniNav;
diff --git a/scripts/db_helper.py b/scripts/db_helper.py
index 199202e..3e3513b 100644
--- a/scripts/db_helper.py
+++ b/scripts/db_helper.py
@@ -84,7 +84,7 @@ def update_topic_status(topic_id: str, status: str, compliance_score: Optional[i
topic.updated_at = datetime.now()
if compliance_score is not None:
topic.compliance_score = compliance_score
- if status in ['ready', 'published'] and topic.generated_at is None:
+ if status in ['ready', 'published']:
topic.generated_at = datetime.now()
db.commit()
return True
diff --git a/scripts/web_search.py b/scripts/web_search.py
index b173d36..42f71d9 100644
--- a/scripts/web_search.py
+++ b/scripts/web_search.py
@@ -143,3 +143,31 @@ def search(query: str, max_results: int = 5) -> List[Dict]:
logger.info(f"搜索 '{query[:20]}' 无结果")
return []
+
+
+def enrich_topic_research(topic: dict, max_results: int = 5) -> str:
+ """对选题进行网络搜索,返回格式化的研究发现文本"""
+ title = topic.get('title', '')
+ field = topic.get('field', '')
+ queries = [title]
+ if field and field not in title:
+ queries.append(f"{field} {title[:40]}")
+ seen_urls = set()
+ results = []
+ for q in queries:
+ for r in search(q, max_results):
+ url = r.get('url', '')
+ if url and url not in seen_urls:
+ seen_urls.add(url)
+ results.append(r)
+ if not results:
+ return ""
+ lines = ["\n## 网络搜索参考", ""]
+ for r in results[:max_results]:
+ snippet = r.get('snippet', r.get('content', ''))
+ lines.append(f"- **{r.get('title', '无标题')}**")
+ lines.append(f" {snippet[:200]}")
+ if r.get('url'):
+ lines.append(f" [{r['url']}]")
+ lines.append("")
+ return "\n".join(lines)