新增用户管理/角色管理/菜单管理功能,修复创作流水线研究脚本

- 用户管理:新增编辑弹窗(修改用户名/角色/密码),增加组织/创建时间/最后登录列
- 角色管理:新增 Role 模型 + CRUD API,admin.html 新增角色管理 tab
- 菜单管理:新增 Menu 模型 + CRUD API,导航栏从 API 动态加载菜单项
- 个人中心:右上角下拉菜单(个人信息/修改密码/退出),新增修改密码 API
- 种子数据:initial_data.py 自动创建默认角色(admin/editor)和默认菜单(7项)
- 修复 research.py 缺少 enrich_topic_research 函数导致导入失败
- 修复 db_helper.py 中 generated_at 条件导致重创作不更新时间戳
- admin.html 操作列加宽防止按钮换行,平台配置增加删除按钮
- articles.html 预览弹窗加 lock-scroll=false 防止页面尺寸跳动
This commit is contained in:
Yuzhiran Dev
2026-05-22 18:34:27 +08:00
parent 1855f190f5
commit b2d043b231
14 changed files with 811 additions and 139 deletions
+25 -2
View File
@@ -7,8 +7,8 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
from ..database import get_db from ..database import get_db
from ..models import User from ..models import User, Role
from ..schemas import LoginRequest, TokenResponse, UserResponse from ..schemas import LoginRequest, TokenResponse, UserResponse, ChangePasswordRequest
from ..core.audit_logger import audit_log from ..core.audit_logger import audit_log
# 加载环境变量 # 加载环境变量
@@ -169,3 +169,26 @@ def get_me(
"""获取当前登录用户信息""" """获取当前登录用户信息"""
return {"user": UserResponse.model_validate(current_user)} 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": "密码修改成功"}
+62
View File
@@ -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": "删除成功"}
+52
View File
@@ -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": "删除成功"}
+21
View File
@@ -104,6 +104,18 @@ def init_db():
("trend_field_mappings", "field_name", "VARCHAR"), ("trend_field_mappings", "field_name", "VARCHAR"),
("trend_field_mappings", "sort_order", "INTEGER DEFAULT 0"), ("trend_field_mappings", "sort_order", "INTEGER DEFAULT 0"),
("trend_field_mappings", "is_active", "BOOLEAN DEFAULT TRUE"), ("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: try:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {col} {typ}")) 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}")) conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {typ}"))
except Exception: except Exception:
pass 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() conn.commit()
except Exception: except Exception:
pass # SQLite 不支持 IF NOT EXISTS,但 create_all 对 SQLite 够用,这里仅为 PostgreSQL 迁移 pass # SQLite 不支持 IF NOT EXISTS,但 create_all 对 SQLite 够用,这里仅为 PostgreSQL 迁移
+24 -1
View File
@@ -6,7 +6,7 @@ from .database import SessionLocal, init_db
from .models import ( from .models import (
Topic, TopicField, TopicConfigField, TopicStatusConfig, Topic, TopicField, TopicConfigField, TopicStatusConfig,
User, Case, LLMConfig, SystemConfig, PlatformConfig, User, Case, LLMConfig, SystemConfig, PlatformConfig,
CollectorCategory, CollectorSource CollectorCategory, CollectorSource, Role, Menu
) )
import bcrypt import bcrypt
@@ -252,6 +252,29 @@ def import_initial_data():
db.commit() db.commit()
print(f"✅ 导入 {len(cases_data)} 条案例") 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 自增序列 # 同步 PostgreSQL 自增序列
if os.getenv('USE_POSTGRES', 'true').lower() == 'true': if os.getenv('USE_POSTGRES', 'true').lower() == 'true':
try: try:
+7 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from .database import engine, get_db, init_db from .database import engine, get_db, init_db
from .models import Base 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 .initial_data import import_initial_data
from .core.scheduler import scheduler from .core.scheduler import scheduler
@@ -44,7 +44,10 @@ app.add_middleware(
) )
# 初始化数据库 # 初始化数据库
try:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
except Exception:
pass
init_db() init_db()
import_initial_data() import_initial_data()
@@ -97,6 +100,9 @@ app.include_router(platform_config.router)
app.include_router(collector_mgmt.router) app.include_router(collector_mgmt.router)
app.include_router(assistant.router) app.include_router(assistant.router)
app.include_router(config_items.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 PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
+48
View File
@@ -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): class TopicField(Base):
__tablename__ = "topic_fields" __tablename__ = "topic_fields"
+60
View File
@@ -389,11 +389,18 @@ class UserUpdate(BaseModel):
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
org_id: Optional[str] = None org_id: Optional[str] = None
last_login: Optional[datetime] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
@@ -545,3 +552,56 @@ class SystemStatus(BaseModel):
execution_time: Optional[float] = None 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)
+246 -106
View File
@@ -23,11 +23,12 @@
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 系统管理</h2> <h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 系统管理</h2>
<div class="filter-bar"> <div class="filter-bar">
<el-button size="default" :type="activeTab === 'users' ? 'primary' : ''" @click="switchTab('users')">用户管理</el-button> <el-button size="default" :type="activeTab === 'users' ? 'primary' : ''" @click="switchTab('users')">用户管理</el-button>
<el-button size="default" :type="activeTab === 'tasklogs' ? 'primary' : ''" @click="switchTab('tasklogs')">任务日志</el-button>
<el-button size="default" :type="activeTab === 'llmconfigs' ? 'primary' : ''" @click="switchTab('llmconfigs')">LLM配置</el-button> <el-button size="default" :type="activeTab === 'llmconfigs' ? 'primary' : ''" @click="switchTab('llmconfigs')">LLM配置</el-button>
<el-button size="default" :type="activeTab === 'platformconfigs' ? 'primary' : ''" @click="switchTab('platformconfigs')">平台配置</el-button> <el-button size="default" :type="activeTab === 'platformconfigs' ? 'primary' : ''" @click="switchTab('platformconfigs')">平台配置</el-button>
<el-button size="default" :type="activeTab === 'systemconfigs' ? 'primary' : ''" @click="switchTab('systemconfigs')">系统配置</el-button> <el-button size="default" :type="activeTab === 'systemconfigs' ? 'primary' : ''" @click="switchTab('systemconfigs')">系统配置</el-button>
<el-button size="default" :type="activeTab === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button> <el-button size="default" :type="activeTab === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button>
<el-button size="default" :type="activeTab === 'roles' ? 'primary' : ''" @click="switchTab('roles')">角色管理</el-button>
<el-button size="default" :type="activeTab === 'menus' ? 'primary' : ''" @click="switchTab('menus')">菜单管理</el-button>
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button> <el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button> <el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
</div> </div>
@@ -47,11 +48,23 @@
</template> </template>
<template v-else> <template v-else>
<el-table :data="paginatedUsers" border stripe class="data-table" style="width:100%"> <el-table :data="paginatedUsers" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column> <el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="username" label="用户名" min-width="120"></el-table-column> <el-table-column prop="username" label="用户名" min-width="100"></el-table-column>
<el-table-column prop="role" label="角色" width="80"></el-table-column> <el-table-column prop="role" label="角色" width="80">
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope"> <template #default="scope">
<el-tag :type="scope.row.role === 'admin' ? 'danger' : 'warning'" size="small">{{ scope.row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="org_id" label="组织" width="70"></el-table-column>
<el-table-column label="创建时间" width="140">
<template #default="scope">{{ scope.row.created_at ? new Date(scope.row.created_at).toLocaleString('zh-CN') : '-' }}</template>
</el-table-column>
<el-table-column label="最后登录" width="140">
<template #default="scope">{{ scope.row.last_login ? new Date(scope.row.last_login).toLocaleString('zh-CN') : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button size="small" @click="showEditUserDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteUser(scope.row.id)">删除</el-button> <el-button size="small" type="danger" @click="deleteUser(scope.row.id)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -64,16 +77,19 @@
<div v-for="item in paginatedUsers" :key="item.id" class="card-item"> <div v-for="item in paginatedUsers" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">用户名</span><span class="card-value">{{ item.username }}</span></div> <div class="card-row"><span class="card-label">用户名</span><span class="card-value">{{ item.username }}</span></div>
<div class="card-row"><span class="card-label">角色</span><span class="card-value">{{ item.role }}</span></div> <div class="card-row"><span class="card-label">角色</span><span class="card-value">{{ item.role }}</span></div>
<div class="card-row"><span class="card-label">组织</span><span class="card-value">{{ item.org_id }}</span></div>
<div class="card-row"><span class="card-label">创建</span><span class="card-value">{{ item.created_at ? new Date(item.created_at).toLocaleDateString('zh-CN') : '-' }}</span></div>
<div class="card-actions"> <div class="card-actions">
<el-button size="small" @click="showEditUserDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteUser(item.id)">删除</el-button> <el-button size="small" type="danger" @click="deleteUser(item.id)">删除</el-button>
</div> </div>
</div> </div>
</div> </div>
<el-dialog v-model="userDialogVisible" title="新增用户" width="450px" :close-on-click-modal="false"> <el-dialog v-model="userDialogVisible" :title="userDialogTitle" width="450px" :close-on-click-modal="false">
<el-form :model="userForm" label-width="80px"> <el-form :model="userForm" label-width="80px">
<el-form-item label="用户名"><el-input v-model="userForm.username" placeholder="至少2个字符"/></el-form-item> <el-form-item label="用户名"><el-input v-model="userForm.username" placeholder="至少2个字符" :disabled="userDialogTitle !== '新增用户'"/></el-form-item>
<el-form-item label="密码"><el-input v-model="userForm.password" type="password" show-password placeholder="至少6位"/></el-form-item> <el-form-item :label="userDialogTitle === '新增用户' ? '密码' : '新密码'"><el-input v-model="userForm.password" type="password" show-password :placeholder="userDialogTitle === '新增用户' ? '至少6位' : '留空则不修改'"/></el-form-item>
<el-form-item label="角色"> <el-form-item label="角色">
<el-select v-model="userForm.role" style="width:100%"> <el-select v-model="userForm.role" style="width:100%">
<el-option label="编辑" value="editor"></el-option> <el-option label="编辑" value="editor"></el-option>
@@ -83,45 +99,11 @@
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="userDialogVisible = false">取消</el-button> <el-button @click="userDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUser" :loading="userSubmitting">创建</el-button> <el-button type="primary" @click="submitUser" :loading="userSubmitting">{{ userDialogTitle === '新增用户' ? '创建' : '保存' }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
<div v-if="activeTab === 'tasklogs'">
<div class="toolbar">
<el-button size="small" @click="loadTaskLogs()">刷新</el-button>
</div>
<div v-if="taskLogsLoading" class="card-loading">加载中...</div>
<template v-else-if="taskLogs.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
<div class="empty-text">暂无任务日志</div>
</div>
</template>
<template v-else>
<el-table :data="taskLogs" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="task_name" label="任务名称" min-width="120"></el-table-column>
<el-table-column prop="topic_id" label="选题" width="100"></el-table-column>
<el-table-column prop="status" label="状态" width="80"></el-table-column>
<el-table-column prop="message" label="消息" min-width="150" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="started_at" label="开始" width="150"></el-table-column>
<el-table-column prop="finished_at" label="结束" width="150"></el-table-column>
<el-table-column prop="duration" label="耗时" width="80"></el-table-column>
</el-table>
</template>
<div class="card-list-mobile">
<div v-for="item in taskLogs" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">任务</span><span class="card-value">{{ item.task_name }}</span></div>
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ item.topic_id }}</span></div>
<div class="card-row"><span class="card-label">状态</span><span class="card-value">{{ item.status }}</span></div>
<div class="card-row"><span class="card-label">消息</span><span class="card-value">{{ item.message }}</span></div>
<div class="card-row"><span class="card-label">耗时</span><span class="card-value">{{ item.duration }}s</span></div>
</div>
</div>
</div>
<div v-if="activeTab === 'llmconfigs'"> <div v-if="activeTab === 'llmconfigs'">
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" size="small" @click="showLLMConfigDialog()">新增配置</el-button> <el-button type="primary" size="small" @click="showLLMConfigDialog()">新增配置</el-button>
@@ -154,7 +136,7 @@
<el-table-column label="系统提示词" min-width="150"> <el-table-column label="系统提示词" min-width="150">
<template #default="scope">{{ (scope.row.system_prompt || '').slice(0, 30) }}{{ (scope.row.system_prompt || '').length > 30 ? '...' : '' }}</template> <template #default="scope">{{ (scope.row.system_prompt || '').slice(0, 30) }}{{ (scope.row.system_prompt || '').length > 30 ? '...' : '' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="130" fixed="right"> <el-table-column label="操作" width="150" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button size="small" @click="showLLMConfigDialog(scope.row)">编辑</el-button> <el-button size="small" @click="showLLMConfigDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteLLMConfig(scope.row.id)">删除</el-button> <el-button size="small" type="danger" @click="deleteLLMConfig(scope.row.id)">删除</el-button>
@@ -219,9 +201,10 @@
<el-table-column prop="is_active" label="激活" width="70"> <el-table-column prop="is_active" label="激活" width="70">
<template #default="scope"><span>{{ scope.row.is_active ? '是' : '否' }}</span></template> <template #default="scope"><span>{{ scope.row.is_active ? '是' : '否' }}</span></template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80" fixed="right"> <el-table-column label="操作" width="150" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button size="small" @click="showPcDialogFn(scope.row)">编辑</el-button> <el-button size="small" @click="showPcDialogFn(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deletePlatformConfig(scope.row.platform)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -260,6 +243,7 @@
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
<div v-if="activeTab === 'systemconfigs'"> <div v-if="activeTab === 'systemconfigs'">
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" size="small" @click="showSystemConfigDialog()">新增配置</el-button> <el-button type="primary" size="small" @click="showSystemConfigDialog()">新增配置</el-button>
@@ -353,6 +337,108 @@
</div> </div>
</div> </div>
<div v-if="activeTab === 'roles'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showRoleDialog()">新增角色</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ roles.length }} 个</span>
</div>
<div v-if="rolesLoading" class="card-loading">加载中...</div>
<template v-else-if="roles.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconSetting /></el-icon>
<div class="empty-text">暂无角色,请先创建</div>
</div>
</template>
<template v-else>
<el-table :data="roles" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
<el-table-column prop="is_system" label="系统" width="60">
<template #default="scope">
<el-icon v-if="scope.row.is_system" style="color:#67C23A;"><IconCheck /></el-icon>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button size="small" @click="showRoleDialog(scope.row)" :disabled="scope.row.is_system">编辑</el-button>
<el-button size="small" type="danger" @click="deleteRole(scope.row.id)" :disabled="scope.row.is_system">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<el-dialog v-model="roleDialogVisible" :title="roleDialogTitle" width="450px" :close-on-click-modal="false">
<el-form :model="roleForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="roleForm.name" placeholder="如:editor"/></el-form-item>
<el-form-item label="描述"><el-input v-model="roleForm.description" type="textarea" :rows="3" placeholder="角色说明"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="roleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveRole" :loading="roleSaving">{{ roleDialogTitle === '新增角色' ? '创建' : '保存' }}</el-button>
</template>
</el-dialog>
</div>
<div v-if="activeTab === 'menus'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showMenuDialog()">新增菜单</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ menus.length }} 个</span>
</div>
<div v-if="menusLoading" class="card-loading">加载中...</div>
<template v-else-if="menus.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconMenu /></el-icon>
<div class="empty-text">暂无菜单,请先创建</div>
</div>
</template>
<template v-else>
<el-table :data="menus" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
<el-table-column prop="path" label="路径" min-width="150"></el-table-column>
<el-table-column prop="icon" label="图标" width="80"></el-table-column>
<el-table-column prop="sort_order" label="排序" width="60"></el-table-column>
<el-table-column prop="roles" label="可见角色" min-width="160">
<template #default="scope">
<el-tag v-for="r in (scope.row.roles || [])" :key="r" size="small" style="margin-right:4px;">{{ r }}</el-tag>
<span v-if="!scope.row.roles || scope.row.roles.length === 0" style="color:#909399;">全部</span>
</template>
</el-table-column>
<el-table-column prop="is_active" label="激活" width="60">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button size="small" @click="showMenuDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteMenu(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<el-dialog v-model="menuDialogVisible" :title="menuDialogTitle" width="500px" :close-on-click-modal="false">
<el-form :model="menuForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="menuForm.name" placeholder="如:仪表盘"/></el-form-item>
<el-form-item label="路径"><el-input v-model="menuForm.path" placeholder="如:/ 或 topics.html"/></el-form-item>
<el-form-item label="图标"><el-input v-model="menuForm.icon" placeholder="如:IconHome"/></el-form-item>
<el-form-item label="排序"><el-input-number v-model="menuForm.sort_order" :min="0" style="width:100%"/></el-form-item>
<el-form-item label="可见角色">
<el-select v-model="menuForm.roles" multiple style="width:100%" placeholder="不选则全部可见">
<el-option v-for="r in roles" :key="r.name" :label="r.name" :value="r.name"></el-option>
</el-select>
</el-form-item>
<el-form-item label="激活"><el-switch v-model="menuForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="menuDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveMenu" :loading="menuSaving">{{ menuDialogTitle === '新增菜单' ? '创建' : '保存' }}</el-button>
</template>
</el-dialog>
</div>
<div v-if="activeTab === 'logs'"> <div v-if="activeTab === 'logs'">
<div class="toolbar"> <div class="toolbar">
<el-select v-model="logType" placeholder="日志类型" style="width:200px;"> <el-select v-model="logType" placeholder="日志类型" style="width:200px;">
@@ -499,21 +585,12 @@
const redirectToPage = (page) => { window.location.href = page.startsWith('/') ? page : '/' + page; }; 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 llmConfigsLoading = ref(false);
const llmConfigDialogVisible = 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 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 editingLLMConfigId = ref(null);
const loadLLMConfigs = async () => { const loadLLMConfigs = async () => {
llmConfigsLoading.value = true; llmConfigsLoading.value = true;
try { llmConfigs.value = await api.get('/api/admin/llmconfigs'); } catch (e) { ElMessage.error('加载LLM配置失败: ' + e.message); } try { llmConfigs.value = await api.get('/api/admin/llmconfigs'); } catch (e) { ElMessage.error('加载LLM配置失败: ' + e.message); }
@@ -522,11 +599,7 @@
const showLLMConfigDialog = (row = null) => { const showLLMConfigDialog = (row = null) => {
if (row) { if (row) {
llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id; llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id;
llmConfigForm.id = row.id; llmConfigForm.name = row.name; llmConfigForm.provider = row.provider || 'opencode-go'; Object.assign(llmConfigForm, row);
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;
} else { } else {
llmConfigDialogTitle.value = '新增配置'; editingLLMConfigId.value = null; 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';
@@ -550,42 +623,38 @@
const platformConfigs = ref([]); const platformConfigs = ref([]);
const platformConfigsLoading = ref(false); const platformConfigsLoading = ref(false);
const platformConfigsRequested = ref(false); const platformConfigsRequested = ref(false);
const pcShowActiveOnly = ref(true); const pcShowActiveOnly = ref(false);
const showPcDialog = ref(false);
const pcDialogTitle = ref('新增配置');
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 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 editingPcPlatform = ref(null);
const loadPlatformConfigs = async () => { const showPcDialog = ref(false);
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 showPcDialogFn = (row = null) => { const showPcDialogFn = (row = null) => {
try { if (row) { pcDialogTitle.value = '编辑平台'; editingPcPlatform.value = row.platform; pcForm.value = { ...row }; }
if (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 }; }
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; showPcDialog.value = true;
} catch (e) { console.error('showPcDialogFn error:', e); }
}; };
const savePcForm = async () => { const savePcForm = async () => {
try { 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('创建成功'); } else { await api.post('/api/platform-config', pcForm.value); ElMessage.success('创建成功'); }
showPcDialog.value = false; await loadPlatformConfigs(); showPcDialog.value = false; await loadPlatformConfigs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); } } 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 systemConfigs = ref([]);
const systemConfigsLoading = ref(false); const systemConfigsLoading = ref(false);
@@ -600,7 +669,12 @@
const orgDialogTitle = ref('新增组织'); const orgDialogTitle = ref('新增组织');
const orgForm = reactive({ org_id: '', name: '', description: '' }); const orgForm = reactive({ org_id: '', name: '', description: '' });
const editingOrgId = ref(null); 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 () => { const loadOrgs = async () => {
orgsLoading.value = true; orgsLoading.value = true;
try { orgs.value = await api.get('/api/admin/orgs'); } catch (e) { ElMessage.error('加载组织失败: ' + e.message); } try { orgs.value = await api.get('/api/admin/orgs'); } catch (e) { ElMessage.error('加载组织失败: ' + e.message); }
@@ -612,26 +686,87 @@
orgDialogVisible.value = true; orgDialogVisible.value = true;
}; };
const saveOrg = async () => { const saveOrg = async () => {
if (!orgForm.org_id || !orgForm.name) { ElMessage.warning('请填写组织ID和名称'); return; }
try { try {
if (editingOrgId.value) { await api.put(`/api/admin/orgs/${editingOrgId.value}`, orgForm); ElMessage.success('更新成功'); } if (editingOrgId.value) { await api.put(`/api/admin/orgs/${editingOrgId.value}`, orgForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/orgs', orgForm); ElMessage.success('创建成功'); } else { await api.post('/api/admin/orgs', orgForm); ElMessage.success('创建成功'); }
orgDialogVisible.value = false; await loadOrgs(); orgDialogVisible.value = false; await loadOrgs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); } } catch (e) { ElMessage.error('保存失败: ' + e.message); }
}; };
const deleteOrg = async (orgId) => { const deleteOrg = async (id) => {
try { await ElMessageBox.confirm('确定删除该组织吗?关联的用户和选题不会被删除。', '提示', { type: 'warning' }); await api.delete(`/api/admin/orgs/${orgId}`); ElMessage.success('删除成功'); await loadOrgs(); } 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); } 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 users = ref([]);
const usersLoading = ref(false); const usersLoading = ref(false);
const userDialogVisible = ref(false); const userDialogVisible = ref(false);
const userDialogTitle = ref('新增用户');
const userSubmitting = ref(false); 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 userPage = ref(1);
const userPageSize = 10; const userPageSize = 10;
const paginatedUsers = computed(() => { const paginatedUsers = computed(() => {
@@ -643,17 +778,24 @@
try { users.value = await api.get('/api/admin/users'); } catch (e) { ElMessage.error('加载用户失败: ' + e.message); } try { users.value = await api.get('/api/admin/users'); } catch (e) { ElMessage.error('加载用户失败: ' + e.message); }
finally { usersLoading.value = false; } 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 () => { const submitUser = async () => {
if (!userForm.username || userForm.username.length < 2) { ElMessage.warning('用户名至少2个字符'); return; } 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; userSubmitting.value = true;
try { try {
const u = await api.post('/api/admin/users', { ...userForm }); if (userForm.id) {
users.value.push(u); 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('创建成功'); ElMessage.success('创建成功');
userDialogVisible.value = false; }
} catch (e) { ElMessage.error('创建失败: ' + e.message); } userDialogVisible.value = false; await fetchUsers();
} catch (e) { ElMessage.error((userForm.id ? '更新' : '创建') + '失败: ' + e.message); }
finally { userSubmitting.value = false; } finally { userSubmitting.value = false; }
}; };
const deleteUser = async (id) => { const deleteUser = async (id) => {
@@ -665,8 +807,6 @@
ElMessage.success('删除成功'); ElMessage.success('删除成功');
} catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); } } catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
}; };
const logType = ref('creator');
const logDate = ref(new Date().toISOString().slice(0, 10)); const logDate = ref(new Date().toISOString().slice(0, 10));
const logContent = ref(''); const logContent = ref('');
const logsLoading = ref(false); 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 logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
const tabLoaders = { const tabLoaders = {
tasklogs: loadTaskLogs,
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs, llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
users: fetchUsers, users: fetchUsers,
orgs: loadOrgs, assistant: loadAssistantConfig, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig, logs: fetchLogs,
}; };
const loadedTabs = new Set([]); const loadedTabs = new Set([]);
@@ -760,15 +899,16 @@
return { return {
activeTab, switchTab, activeTab, switchTab,
taskLogs, taskLogsLoading, loadTaskLogs,
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig, llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
platformConfigs, platformConfigsLoading, platformConfigsRequested, pcShowActiveOnly, showPcDialog, pcForm, pcDialogTitle, showPcDialogFn, savePcForm, editingPcPlatform, platformConfigs, platformConfigsLoading, platformConfigsRequested, pcShowActiveOnly, showPcDialog, pcForm, pcDialogTitle, showPcDialogFn, savePcForm, editingPcPlatform,
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig, systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg, orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
orgPage, orgPageSize, paginatedOrgs, 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, logout, currentUser, isAdmin, redirectToPage,
users, usersLoading, userDialogVisible, userSubmitting, userForm, userPage, userPageSize, paginatedUsers, users, usersLoading, userDialogVisible, userDialogTitle, userSubmitting, userForm, userPage, userPageSize, paginatedUsers,
fetchUsers, addUser, submitUser, deleteUser, fetchUsers, addUser, showEditUserDialog, submitUser, deleteUser,
logType, logDate, logContent, logsLoading, fetchLogs, logType, logDate, logContent, logsLoading, fetchLogs,
assistantPrompt, assistantEnabled, assistantSaving, saveAssistantPrompt, assistantPrompt, assistantEnabled, assistantSaving, saveAssistantPrompt,
}; };
+1 -1
View File
@@ -106,7 +106,7 @@
</div> </div>
</main> </main>
</div> </div>
<el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape> <el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape :lock-scroll="false">
<div v-if="previewArticleData"> <div v-if="previewArticleData">
<div style="display:flex; gap:16px; margin-bottom:12px; flex-wrap:wrap;"> <div style="display:flex; gap:16px; margin-bottom:12px; flex-wrap:wrap;">
<div style="flex:1; min-width:200px;"> <div style="flex:1; min-width:200px;">
+113 -9
View File
@@ -19,7 +19,8 @@
.module-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-md); } .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-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 { 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 { 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 { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dashed var(--color-border); }
.module-content div:last-child { border-bottom: none; } .module-content div:last-child { border-bottom: none; }
@@ -95,18 +96,31 @@
</span> </span>
</h3> </h3>
<div class="module-grid"> <div class="module-grid">
<div v-for="mod in modules" :key="mod.id" class="module-card"> <div v-for="mod in modules" :key="mod.module_id" class="module-card">
<div class="module-header"> <div class="module-header">
<span class="module-title">{{ mod.title }}</span> <span class="module-title">{{ mod.title }}</span>
<span :class="['module-status', mod.status === 'running' ? 'running' : '']">{{ mod.status === 'running' ? '运行中' : '已停止' }}</span> <span :class="['module-status', mod.enabled ? '' : 'inactive']">
<el-icon v-if="mod.enabled" style="vertical-align:-2px;"><IconCheck /></el-icon>
<el-icon v-else style="vertical-align:-2px;"><IconClose /></el-icon>
{{ mod.enabled ? '已启用' : '已禁用' }}
</span>
</div> </div>
<div class="module-content"> <div class="module-content">
<div><span>最后运行</span><span>{{ mod.last_run }}</span></div> <div><span>执行时间</span><span>{{ mod.schedule || mod.cron_default }}</span></div>
<div><span>下次运行</span><span>{{ mod.next_run }}</span></div> <div><span>成功/失败</span><span style="color:#67c23a;">{{ mod.success_runs }}</span> / <span style="color:#f56c6c;">{{ mod.failed_runs }}</span></div>
<div><span>今日任务</span><span>{{ mod.task_count }} </span></div> <div><span>今日任务</span><span>{{ mod.total_runs }} </span></div>
<div><span>成功率</span><span>{{ mod.success_rate > 0 ? mod.success_rate + '%' : '暂无' }}</span></div> <div><span>下次运行</span><span style="color:#409eff;">{{ mod.next_run || '—' }}</span></div>
<div v-if="mod.last_result && mod.last_result.topics_count" style="border-bottom:none;">
<span>最新产出</span>
<span style="color:#409eff;font-weight:600;">{{ mod.last_result.topics_count }} 个选题</span>
</div>
<div v-else-if="mod.last_result && mod.last_result.articles_synced" style="border-bottom:none;">
<span>最新产出</span>
<span style="color:#409eff;font-weight:600;">{{ mod.last_result.articles_synced }} 篇</span>
</div>
<div style="margin-top:10px; border-bottom:none;"> <div style="margin-top:10px; border-bottom:none;">
<el-button size="small" type="primary" @click="triggerModule(mod.id)" :loading="runningModule === mod.id">立即运行</el-button> <el-button v-if="isAdmin" size="small" type="primary" @click="openModuleDetail(mod)">详情</el-button>
<el-button size="small" @click="triggerModule(mod.module_id || mod.id)" :loading="runningModule === (mod.module_id || mod.id)" :disabled="!mod.enabled">立即运行</el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -132,6 +146,56 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 模块详情抽屉 -->
<el-drawer v-model="showModuleDrawer" :title="(moduleDetail ? moduleDetail.title : '') + ' 详情'" size="50%" direction="rtl" class="module-drawer">
<div v-if="moduleDetailLoading" style="text-align:center;padding:40px;color:#909399;">加载中...</div>
<div v-else-if="moduleDetail" style="padding:0 20px;">
<div style="margin-bottom:16px;display:flex;gap:16px;align-items:center;">
<el-tag :type="moduleDetail.enabled ? 'success' : 'info'" size="small">{{ moduleDetail.enabled ? '已启用' : '已禁用' }}</el-tag>
<span style="font-size:13px;color:#909399;">执行时间: {{ moduleDetail.schedule || moduleDetail.cron_default }}</span>
</div>
<el-tabs style="height:calc(100vh - 200px);">
<el-tab-pane label="📋 运行记录" name="history" style="overflow:auto;">
<div v-if="moduleDetailHistory && moduleDetailHistory.length > 0">
<el-timeline>
<el-timeline-item v-for="(h, i) in moduleDetailHistory" :key="h.id || i"
:timestamp="h.started_at ? new Date(h.started_at).toLocaleString('zh-CN', {timeZone:'Asia/Shanghai'}) : '—'"
:color="h.status === 'success' ? '#67c23a' : h.status === 'running' ? '#e6a23c' : '#f56c6c'">
<div style="font-size:13px;">
<el-tag :type="h.status === 'success' ? 'success' : h.status === 'running' ? 'warning' : 'danger'" size="small" style="margin-right:6px;">{{ h.status }}</el-tag>
<span v-if="h.message">{{ h.message }}</span>
<span v-else style="color:#909399;"></span>
</div>
<div v-if="h.error_trace" style="margin-top:4px;padding:6px 8px;background:#fef0f0;border-radius:4px;font-size:12px;color:#f56c6c;max-height:60px;overflow:auto;">{{ h.error_trace }}</div>
<div style="font-size:12px;color:#c0c4cc;margin-top:2px;">耗时 {{ h.duration ? h.duration + 's' : '—' }} · {{ h.triggered_by }}</div>
</el-timeline-item>
</el-timeline>
</div>
<div v-else style="color:#909399;padding:20px;text-align:center;">暂无运行记录</div>
</el-tab-pane>
<el-tab-pane label="🤖 提示词" name="prompts" style="overflow:auto;">
<div v-if="moduleDetailPrompts && moduleDetailPrompts.length > 0">
<div v-for="p in moduleDetailPrompts" :key="p.id" style="margin-bottom:12px;border:1px solid #ebeef5;border-radius:10px;padding:12px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
<div>
<el-tag size="small" type="info" style="margin-right:6px;">{{ p.category }}</el-tag>
<span style="font-weight:600;font-size:13px;">{{ p.key }}</span>
<div style="font-size:11px;color:#909399;margin-top:2px;">{{ p.description }}</div>
</div>
<el-switch v-model="p.enabled" @change="savePrompt(p)" size="small" :disabled="promptSaving === p.id"></el-switch>
</div>
<el-input type="textarea" v-model="p.content" :rows="5" style="font-family:monospace;font-size:12px;" @blur="savePrompt(p)"></el-input>
</div>
</div>
<div v-else style="color:#909399;padding:20px;text-align:center;">此模块暂无提示词配置</div>
</el-tab-pane>
</el-tabs>
<div style="padding-top:16px;border-top:1px solid #ebeef5;text-align:center;">
<el-button type="primary" @click="triggerModule(moduleDetail.module_id)" :loading="runningModule === moduleDetail.module_id" :disabled="!moduleDetail.enabled">立即运行此任务</el-button>
</div>
</div>
</el-drawer>
</main> </main>
</div> </div>
@@ -163,7 +227,13 @@
schedulerRunning: false, schedulerRunning: false,
upcomingEntries: [], upcomingEntries: [],
loadingPlan: false, loadingPlan: false,
runningModule: null runningModule: null,
showModuleDrawer: false,
moduleDetail: null,
moduleDetailLoading: false,
moduleDetailHistory: [],
moduleDetailPrompts: [],
promptSaving: null
}; };
}, },
methods: { methods: {
@@ -311,6 +381,40 @@
}, },
redirectToPage(page) { redirectToPage(page) {
window.location.href = page.startsWith('/') ? page : '/' + 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() { mounted() {
+118 -13
View File
@@ -153,10 +153,24 @@
}, },
emits: ['logout'], emits: ['logout'],
data: function () { 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: { computed: {
items: function () { 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); return navItems(this.isAdmin);
}, },
page: function () { page: function () {
@@ -167,31 +181,79 @@
}, },
}, },
methods: { 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) { navigate: function (page) {
this.dropdownOpen = false; this.dropdownOpen = false; this.profileOpen = false;
if (this.onNavigate) { this.onNavigate(page); return; } if (this.onNavigate) { this.onNavigate(page); return; }
window.location.href = page === '/' ? '/' : '/' + page; window.location.href = page === '/' ? '/' : '/' + page;
}, },
logout: function () { logout: function () {
this.profileOpen = false;
this.$emit('logout'); this.$emit('logout');
}, },
toggleDropdown: function () { toggleDropdown: function () {
this.dropdownOpen = !this.dropdownOpen; this.dropdownOpen = !this.dropdownOpen; this.profileOpen = false;
}, },
closeDropdown: function (e) { toggleProfile: function () {
if (this.dropdownOpen && !this.$el.contains(e.target)) { this.profileOpen = !this.profileOpen; this.dropdownOpen = false;
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 () { mounted: function () {
document.addEventListener('click', this.closeDropdown); document.addEventListener('click', this.closeAll);
this.fetchMenus();
}, },
beforeUnmount: function () { beforeUnmount: function () {
document.removeEventListener('click', this.closeDropdown); document.removeEventListener('click', this.closeAll);
}, },
template: '\ template: '\
<nav class="uni-nav">\ <nav class="uni-nav" style="position:relative;">\
<div class="uni-nav-inner">\ <div class="uni-nav-inner">\
<div class="uni-nav-brand">{{ showTitle }}</div>\ <div class="uni-nav-brand">{{ showTitle }}</div>\
<div class="uni-nav-items">\ <div class="uni-nav-items">\
@@ -203,12 +265,22 @@
<button class="uni-nav-hamburger" @click.stop="toggleDropdown">\ <button class="uni-nav-hamburger" @click.stop="toggleDropdown">\
{{ dropdownOpen ? "✕" : "☰" }}\ {{ dropdownOpen ? "✕" : "☰" }}\
</button>\ </button>\
<div class="uni-nav-avatar">{{ username ? username.charAt(0).toUpperCase() : "?" }}</div>\ <div class="uni-nav-avatar" style="cursor:pointer;" @click.stop="toggleProfile">{{ username ? username.charAt(0).toUpperCase() : "?" }}</div>\
<span v-if="username" class="uni-nav-username">{{ username }}</span>\ <span v-if="username" class="uni-nav-username" style="cursor:pointer;" @click.stop="toggleProfile">{{ username }}</span>\
<span v-if="isAdmin" class="uni-nav-badge">管理员</span>\ <span v-if="isAdmin" class="uni-nav-badge">管理员</span>\
<button class="uni-nav-logout" @click="logout">退出</button>\ <button class="uni-nav-logout" @click="logout">退出</button>\
</div>\ </div>\
</div>\ </div>\
<div v-if="profileOpen" class="uni-nav-profile-menu" @click.stop\
style="position:absolute;top:60px;right:16px;background:#fff;box-shadow:0 4px 16px rgba(0,0,0,0.12);border-radius:8px;z-index:10001;min-width:160px;padding:4px;">\
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;border:none;background:transparent;color:#303133;font-size:13px;border-radius:6px;cursor:pointer;"\
@click="openProfile">📋 个人信息</button>\
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;border:none;background:transparent;color:#303133;font-size:13px;border-radius:6px;cursor:pointer;"\
@click="openPwdDialog">🔑 修改密码</button>\
<div style="height:1px;background:#f0f0f0;margin:4px 0;"></div>\
<button style="display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;border:none;background:transparent;color:#e74c3c;font-size:13px;border-radius:6px;cursor:pointer;"\
@click="logout">🚪 退出登录</button>\
</div>\
<div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\ <div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\
<button v-for="item in items" :key="item.key"\ <button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-dropdown-item\', { active: page === item.key }]"\ :class="[\'uni-nav-dropdown-item\', { active: page === item.key }]"\
@@ -216,7 +288,40 @@
{{ item.label }}\ {{ item.label }}\
</button>\ </button>\
</div>\ </div>\
</nav>', </nav>\
<div v-if="showProfileDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:99999;display:flex;align-items:center;justify-content:center;" @click.self="closeProfileDialog">\
<div style="background:#fff;border-radius:12px;width:380px;max-width:90vw;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,0.2);">\
<h3 style="margin:0 0 16px;font-size:16px;font-weight:600;color:#303133;">个人信息</h3>\
<div style="display:flex;flex-direction:column;gap:10px;">\
<div style="display:flex;gap:8px;font-size:13px;"><span style="color:#909399;min-width:70px;">用户名</span><span style="color:#303133;">{{ profile ? profile.username : \'-\' }}</span></div>\
<div style="display:flex;gap:8px;font-size:13px;"><span style="color:#909399;min-width:70px;">角色</span><span style="color:#303133;">{{ profile ? profile.role : \'-\' }}</span></div>\
<div style="display:flex;gap:8px;font-size:13px;"><span style="color:#909399;min-width:70px;">组织</span><span style="color:#303133;">{{ profile ? (profile.org_id || \'-\') : \'-\' }}</span></div>\
<div style="display:flex;gap:8px;font-size:13px;"><span style="color:#909399;min-width:70px;">创建时间</span><span style="color:#303133;">{{ profile && profile.created_at ? new Date(profile.created_at).toLocaleString(\'zh-CN\') : \'-\' }}</span></div>\
<div style="display:flex;gap:8px;font-size:13px;"><span style="color:#909399;min-width:70px;">最后登录</span><span style="color:#303133;">{{ profile && profile.last_login ? new Date(profile.last_login).toLocaleString(\'zh-CN\') : \'-\' }}</span></div>\
</div>\
<div style="margin-top:16px;text-align:right;"><button style="padding:6px 16px;border:1px solid #dcdfe6;background:#fff;color:#606266;border-radius:6px;cursor:pointer;font-size:13px;" @click="closeProfileDialog">关闭</button></div>\
</div>\
</div>\
<div v-if="showPwdDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:99999;display:flex;align-items:center;justify-content:center;" @click.self="closePwdDialog">\
<div style="background:#fff;border-radius:12px;width:380px;max-width:90vw;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,0.2);">\
<h3 style="margin:0 0 16px;font-size:16px;font-weight:600;color:#303133;">修改密码</h3>\
<div style="display:flex;flex-direction:column;gap:12px;">\
<div><label style="display:block;font-size:13px;color:#606266;margin-bottom:4px;">原密码</label>\
<input v-model="pwdForm.old_password" type="password" placeholder="输入原密码"\
style="width:100%;padding:8px 12px;border:1px solid #dcdfe6;border-radius:6px;font-size:13px;box-sizing:border-box;"></div>\
<div><label style="display:block;font-size:13px;color:#606266;margin-bottom:4px;">新密码</label>\
<input v-model="pwdForm.new_password" type="password" placeholder="至少6位"\
style="width:100%;padding:8px 12px;border:1px solid #dcdfe6;border-radius:6px;font-size:13px;box-sizing:border-box;"></div>\
<div><label style="display:block;font-size:13px;color:#606266;margin-bottom:4px;">确认新密码</label>\
<input v-model="pwdForm.confirm" type="password" placeholder="再次输入新密码"\
style="width:100%;padding:8px 12px;border:1px solid #dcdfe6;border-radius:6px;font-size:13px;box-sizing:border-box;"></div>\
</div>\
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end;">\
<button style="padding:6px 16px;border:1px solid #dcdfe6;background:#fff;color:#606266;border-radius:6px;cursor:pointer;font-size:13px;" @click="closePwdDialog">取消</button>\
<button style="padding:6px 16px;border:none;background:#409eff;color:#fff;border-radius:6px;cursor:pointer;font-size:13px;" @click="submitPwd" :disabled="pwdSubmitting">{{ pwdSubmitting ? "提交中..." : "确定" }}</button>\
</div>\
</div>\
</div>',
}; };
window.UniNav = UniNav; window.UniNav = UniNav;
+1 -1
View File
@@ -84,7 +84,7 @@ def update_topic_status(topic_id: str, status: str, compliance_score: Optional[i
topic.updated_at = datetime.now() topic.updated_at = datetime.now()
if compliance_score is not None: if compliance_score is not None:
topic.compliance_score = compliance_score 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() topic.generated_at = datetime.now()
db.commit() db.commit()
return True return True
+28
View File
@@ -143,3 +143,31 @@ def search(query: str, max_results: int = 5) -> List[Dict]:
logger.info(f"搜索 '{query[:20]}' 无结果") logger.info(f"搜索 '{query[:20]}' 无结果")
return [] 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)